Skip to content

fix(core): fire active-todo reminders at delegation boundaries and user turns - #10963

Open
yiliang114 wants to merge 3 commits into
mainfrom
fix/issue-10953-active-todo-reminder
Open

fix(core): fire active-todo reminders at delegation boundaries and user turns#10963
yiliang114 wants to merge 3 commits into
mainfrom
fix/issue-10953-active-todo-reminder

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

When a session delegates work to a foreground subagent, the parent earns one tool turn per tens of minutes of real work, so the 3-turn budget of the active-todo reminder never comes due and the persisted plan sidecar freezes while work advances. This PR makes two changes, both on the already-existing reminder machinery (takeActiveTodoReminder / startActiveTodoWorkChain), in both frontends (TUI client.ts and ACP Session.ts):

  1. A tool-result batch that carries a top-level Agent tool result now forces the reminder due at that boundary, so the model is re-prompted to update plan nodes exactly when delegated progress arrives — instead of waiting for parent tool turns that delegation never produces.
  2. An ordinary user turn now continues the previous work chain when a reminder is still registered, instead of clearing it. A registered reminder implies the plan still has unfinished items, because todo_write deletes the reminder when the plan completes; this mirrors the existing retry continuation.

Why it's needed

Fixes the failure reported in #10953: the compensating reminder was injected 0 times across an entire delegation-heavy session, the plan froze for ~56 minutes, and the very user message asking about progress discarded the plan context. The reminder existed precisely to bound plan staleness; both of its delivery paths were structurally unreachable in delegation-heavy sessions.

Directions (c) time-aware cadence, (d) making todo_id load-bearing, and (e) delayed tool-response recording are intentionally not included: (c) needs a growth bound of its own, (d) contradicts the observational contract in docs/design/ordinary-session-plan-execution.md and needs a deliberate decision, (e) is a separate defect.

Reviewer Test Plan

How to verify

Unit tests added on both frontends (red before the fix, green after):

cd packages/core && npx vitest run src/core/client.test.ts src/core/client-goal.test.ts src/tools/todoWrite.test.ts
cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/acp-integration/session/Session.worktree.test.ts src/acp-integration/session/Session.review-lease.test.ts src/acp-integration/acpAgent.test.ts
  • forces the active todo reminder due when a agent/task tool result returns (core) and forces the active todo reminder due when an Agent tool result returns (cli): a ToolResult turn whose batch carries an agent/task functionResponse must call takeActiveTodoReminder(promptId, true) and inject the reminder, even though the ordinary budget is not filled.
  • continues the todo work chain on a user turn while a reminder is registered (core + cli): with a registered reminder, the next ordinary user turn must call startActiveTodoWorkChain(newPromptId, previousPromptId).
  • Regression guards kept green: keeps the turn budget for tool results without an Agent execution (non-agent results stay budgeted), clears active todo context when an ordinary prompt starts (no registered reminder → chain still cleared), retry continuation unchanged, plan-completion deletion unchanged (todoWrite.test.ts, config.test.ts).

Typecheck passes for both packages (tsc --noEmit in packages/core and packages/cli).

Evidence (Before & After)

N/A — no user-visible UI change; the reminder text and plan view rendering are unchanged, only when the reminder is delivered. The full-suite runs above are the evidence.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested

Environment (optional)

Unit tests + typecheck only (npm install in a worktree off origin/main @ 661f41eef).

Risk & Scope

  • Main risk or tradeoff: forcing at the agent-result boundary adds at most one reminder injection per completed top-level delegation (the counter resets on injection), and only while a plan is unfinished — bounded, and exactly when new progress information arrives. User turns now preserve an unfinished plan's chain; a chain still ends when todo_write reports all items completed (reminder deleted → next user turn starts fresh).
  • Not validated / out of scope: a live multi-hour delegation session; time-aware cadence (direction c); todo_id becoming load-bearing (direction d); delayed tool-response recording (direction e). The 5 failing tests in packages/core full suite (config.test.ts cron/eager-registration, skill-curator, session-writer-lease, git-branches) fail identically on a pristine origin/main checkout in this environment and are unrelated.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #10953

中文说明

本 PR 做了什么

当会话把活委派给前台子 agent 时,父会话几十分钟才产生一个 tool turn,active-todo 提醒的 3-turn 预算永远凑不满,持久化的 plan sidecar 因此在工作推进时冻结。本 PR 在已有提醒机制(takeActiveTodoReminder / startActiveTodoWorkChain)上做了两处修改,且同时覆盖两个前端(TUI client.ts 与 ACP Session.ts):

  1. 当 tool 结果批次里包含顶层 Agent 工具的结果时,在该边界强制提醒到期——委派出去的进展到达时立即重新提示模型更新 plan 节点,而不是等待委派根本不会产生的父会话 tool turn。
  2. 当仍有已注册提醒时,普通用户轮次延续上一条工作链而不是清空它。已注册提醒即意味着 plan 仍有未完成项,因为 todo_write 在 plan 完成时会删除提醒;这与既有的 Retry 续链行为一致。

为什么需要

修复 #10953 报告的故障:整场委派密集的会话里补偿提醒注入 0 次,plan 冻结约 56 分钟,而且恰恰是询问进展的那条用户消息销毁了 plan 上下文。这个提醒本来就是为约束 plan 陈旧而存在的,但它的两条投递路径在委派密集的会话形态下结构性地不可达。

方向 (c) 时间感知节奏、(d) 让 todo_id 承载状态、(e) 工具响应延迟记录均刻意不包含:(c) 需要单独的增长上界设计;(d) 与 docs/design/ordinary-session-plan-execution.md 的观测性契约冲突,需要明确决策;(e) 是独立缺陷。

审阅者测试计划

如何验证

两个前端各新增单元测试(修复前红、修复后绿):命令同上英文部分。核心断言:带 agent/task functionResponse 的 ToolResult 轮必须以 takeActiveTodoReminder(promptId, true) 强制注入;有已注册提醒时,下一个普通用户轮必须调用 startActiveTodoWorkChain(新promptId, 上一条promptId)。回归保护保持绿色:非 agent 结果仍按预算、无提醒时用户轮照旧清链、Retry 续链不变、plan 完成即删提醒(todoWrite.test.tsconfig.test.ts)。两个包 tsc --noEmit 均通过。

前后对比证据

N/A——无用户可见 UI 变化;提醒文本与 plan 视图渲染不变,只是投递时机改变,以上全量测试即为证据。

测试环境

系统 状态
🍏 macOS ⚠️ 未测试
🪟 Windows ⚠️ 未测试
🐧 Linux ✅ 已测试

环境(可选)

仅单元测试 + typecheck(在基于 origin/main @ 661f41eef 的 worktree 中 npm install)。

风险与范围

  • 主要风险/权衡:agent 结果边界强制注入最多为每次完成的顶层委派增加一次提醒注入(注入即重置计数),且仅在 plan 未完成时发生——有界,并且恰在新进展信息到达时。用户轮次现在会保留未完成 plan 的工作链;当 todo_write 报告全部完成时链仍会结束(提醒被删除→下一个用户轮重新开链)。
  • 未验证/超出范围:真实多小时委派会话;时间感知节奏(方向 c);todo_id 承载状态(方向 d);工具响应延迟记录(方向 e)。core 包全量测试中的 5 个失败(config.test.ts cron/eager 注册、skill-curatorsession-writer-leasegit-branches)在干净 origin/main 检出上于本环境同样失败,与本改动无关。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #10953

…er turns

A session that delegates to foreground subagents earns one parent tool
turn per tens of minutes of real work, so the 3-turn budget of the
active-todo reminder never comes due and the persisted plan freezes
(#10953). Any ordinary user turn additionally discards a registered
reminder, so the very message asking about progress destroys the plan
context.

- Force the reminder due on a tool-result batch that carries a
  top-level Agent tool result, in both the TUI (client.ts) and ACP
  (Session.ts) paths, putting the nudge exactly where delegated
  progress arrives.
- Continue the previous work chain on an ordinary user turn while a
  reminder is still registered — a registered reminder implies
  unfinished items, since todo_write deletes it on plan completion.
  This mirrors the existing retry continuation.

The observational contract of todo_id is unchanged. Time-aware cadence
(direction c) and the delayed tool-response recording (direction e) are
separate concerns and intentionally not included.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtlu4sumgg
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 3, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on 1a35735 did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— 1a35735 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this one is unusually well set up: the linked issue already carries the forensics, and the diff is small enough to hold in your head.

Template ✓ — every section present, including the Risk & Scope bullets and the Chinese translation.

Problem: observed, not theoretical. #10953 documents a plan frozen for 55m44s across four nodes, a reminder injected 0 times in a ~1.6 MB transcript (and 0 times in a second, unrelated ~7.5 MB session), with session/plan/call IDs and absolute UTC timestamps. Our own issue triage verified root causes 1–4 line-by-line against 661f41eef, and you then re-confirmed both delivery paths with red tests. I independently re-verified the cited code at this PR's head: the 3-turn budget (ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3), UserQuery previously calling startActiveTodoWorkChain(prompt_id) with no continuation, and the forced variant being reserved for Retry / Cron / Notification / Teammate. All accurate.

Direction: aligned. Plan-state observability during delegation is squarely inside the session-management mission, and it's the exact session shape the plan feature exists for. It touches no auth, sandbox, model-selection, telemetry, release, or public-contract surface, so nothing to escalate on direction. For what it's worth, the reference agent's CHANGELOG has no direct todo-reminder entry, but delegation-boundary state propagation is a heavily worked area there (foreground-subagent result streaming, backgrounded-turn tool handling, nested subagent transcript delivery) — the area is relevant even without a 1:1 match.

Size: core paths are touched and the change is cross-package (packages/core + packages/cli), so Stage 0 applies. Breakdown: 55 production lines (client.ts +26/−4, Session.ts +23/−2), 196 test lines, 0 generated/schema. That's far below the 500-line escalation and the 1000-line advisory, and the title is a fix, so Tier 1 doesn't apply either. Evaluated under Tier 2.

Approach: this matches what I'd have proposed independently before reading the diff — reuse the machinery that already exists rather than add any. Both halves land on existing primitives (takeActiveTodoReminder's force flag, startActiveTodoWorkChain's continuedFrom), and the agent-result predicate goes through canonicalToolName + ToolNames.AGENT instead of hardcoding 'agent', which is what makes the legacy task alias work for free. Scope is genuinely minimal: directions (c), (d) and (e) are excluded with stated reasons, and (d) is the one that would have broken the observational contract in docs/design/ordinary-session-plan-execution.md. No drive-by refactors or formatting churn. The five one-line test-mock additions are necessary rather than noise — those are exactly the five files that mock these config methods, so nothing was missed and nothing extra was touched.

Cutting 80% wouldn't work here: (a) alone leaves the "asking about progress destroys the plan context" failure, and (b) alone leaves the reminder never coming due. Both are separately observed in the issue.

Risk: Stage 1e matched a high-risk pathpackages/cli/src/acp-integration/session/Session.ts (acp-integration), which correlates with post-merge reverts in this repo. That doesn't block anything, but it raises the review depth: full Stage 2 enrichments, real CI evidence required before any approval, and a named sandboxed lane. Since you have write access, both @qwen-code /verify and @qwen-code /tmux are available — I'll say specifically what each would settle in the Stage 2 comment.

Moving on to code review. 🔍

中文说明

感谢贡献!这个 PR 的前期准备非常充分:关联 issue 已经带了完整的取证信息,diff 也小到可以一眼看完。

模板 ✓ —— 各部分齐全,包括 Risk & Scope 三个要点和中文翻译。

问题: 已观测到的 bug,不是理论性加固。#10953 记录了 plan 冻结 55m44s、四个节点被一次性补刷,整份约 1.6 MB transcript 里提醒注入 0 次(另一份无关的约 7.5 MB 会话同样是 0 次),并给出了 session/plan/call ID 与绝对 UTC 时间戳。我们自己的 issue triage 已针对 661f41eef 逐行核实了原因 1–4,你随后又用红测试复核了两条投递路径。我也在本 PR head 上独立复核了被引用的代码:3-turn 预算(ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3)、UserQuery 此前调用 startActiveTodoWorkChain(prompt_id) 不带续链参数、强制注入变体只留给 Retry / Cron / Notification / Teammate。全部准确。

方向: 对齐。委派期间的 plan 状态可观测性正落在 session-management 的核心使命内,而这恰恰是 plan 功能为之存在的会话形态。改动不涉及 auth、sandbox、模型选择、telemetry、发布或对外契约,因此方向上无需上升。补充一点:参考 agent 的 CHANGELOG 里没有直接对应的 todo 提醒条目,但"委派边界上的状态传递"在那边是被大量打磨的领域(前台子 agent 结果流式转发、后台化轮次的工具处理、嵌套子 agent transcript 投递)——即使不是 1:1 对应,这个方向也是相关的。

规模: 触及核心路径且跨包(packages/core + packages/cli),因此适用 Stage 0。明细:生产代码 55 行client.ts +26/−4、Session.ts +23/−2)、测试 196 行生成/schema 0 行。远低于 500 行的维护者关注阈值和 1000 行的大 PR 建议;标题是 fix,Tier 1 也不适用。按 Tier 2 评估。

方案: 与我在看 diff 之前独立想到的做法一致——复用已有机制,不新增任何东西。两处改动都落在既有原语上(takeActiveTodoReminderforce 参数、startActiveTodoWorkChaincontinuedFrom),而 agent 结果判定走 canonicalToolName + ToolNames.AGENT 而非硬编码 'agent',这也让旧的 task 别名自动生效。范围确实最小:方向 (c)、(d)、(e) 均按理由排除,其中 (d) 正是会破坏 docs/design/ordinary-session-plan-execution.md 观测性契约的那个。没有顺手重构或格式抖动。五处一行测试 mock 补充是必要的而非噪音——它们恰好是全部 mock 了这些 config 方法的五个文件,既没漏也没多改。

砍掉 80% 在这里行不通:只有 (a) 会留下"询问进展反而销毁 plan 上下文"的故障,只有 (b) 会留下提醒永不到期的问题。两者在 issue 里都是各自独立观测到的。

风险: Stage 1e 命中高风险路径 —— packages/cli/src/acp-integration/session/Session.tsacp-integration),该路径与本仓库合并后 revert 相关。这不阻塞任何东西,但会提高 review 深度:完整的 Stage 2 补充信息、任何批准前都需要真实 CI 证据、并点名沙箱验证通道。由于你有写权限,@qwen-code /verify@qwen-code /tmux 都可用——我会在 Stage 2 评论里具体说明各自能验证什么。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 1a35735027581b99a44432bfa7490e73ada98539 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Code review

No critical blockers. I checked every symbol this diff leans on rather than taking the diff's word for it, and the mechanism holds together.

What I verified at this commit:

  • Call-site coverage is complete, which is the thing most likely to be wrong here. The reminder machinery has seven takeActiveTodoReminder consumers across the two frontends. Five of them are turn-start injections (Retry / Cron / Notification / Teammate, plus the ACP user-prompt path) that already pass force = true. The only two that consume under the 3-turn budget are the two this PR changes — the core ToolResult path and ACP #buildNextMessageAfterToolRun. So there is no third frontend left starved, and no already-forced path got double-forced.
  • The invariant change (b) rests on is real. refreshActiveTodoReminder calls setActiveTodoReminder(promptId, undefined) once unfinishedTodos.length === 0, so "a reminder is registered" genuinely does imply "the plan has unfinished items". Without that, continuing the chain on every user turn would leak stale context forever.
  • No missed mocks. Exactly five test files mock these config methods, and all five gained getActiveTodoReminder. A sixth would have thrown at call time, so this is the kind of omission that's easy to make and easy to miss in review.
  • canonicalToolName is doing real work, not decoration — it's what makes the legacy task alias resolve to agent, and it.each(['agent', 'task']) pins that. It also matches the idiom already used for function calls in the ACP session, so the predicate reads like the surrounding code.
  • The tests assert the outcome, not just the call. Both frontends check that the reminder text actually lands in the outgoing request/message, not merely that takeActiveTodoReminder received true. The ACP one drives a real agent tool round-trip through session.prompt(). That's the difference between a test that pins behaviour and one that pins a function signature.
  • Placement is correct in both files: in core the memory prompt is appended so the functionResponse parts still lead when the predicate runs, and the existing insertAt logic already knows how to splice the reminder in behind them. In ACP the predicate sits on toolRun.parts, and the call's position relative to the abortSignal.aborted check is unchanged from before.

Three non-blocking notes, in descending order of how much I'd want a maintainer to look at them:

  1. Change (b) trades one staleness for another, deliberately. A registered reminder now survives every subsequent user turn. The invariant guarantees the plan is unfinished, not that it's still relevant — so if someone abandons a plan mid-flight and pivots to unrelated work without calling todo_write again, the old plan keeps nagging on later turns and at every agent result, where previously the first new user turn wiped it. It's bounded (one string; the continued path deletes reminders under other owners, so nothing accumulates) and it's precisely what Todo plan state goes stale while work is delegated to subagents (the active-todo reminder never fires) #10953 asked for, and the model can clear it. I'm not asking for a change — just putting the tradeoff on record so it's a decision rather than a side effect.
  2. A background agent launch also trips the force. The predicate matches any top-level Agent tool result, and a background launch returns an immediate ack — the real progress arrives later as a completion notification, on a path that already forces. So for background delegation the force fires at launch rather than at progress arrival. Harmless, but the Risk & Scope line says "at most one reminder injection per completed top-level delegation", which reads narrower than the actual behaviour.
  3. Two adjacent flags in the ACP session now mean different things. #resetTodoStopGuardBackgroundLineage() is still gated on continuesCurrentWorkChain while the todo chain continues on the new continuesTodoWorkChain, one line apart, in a 14k-line file. I checked that this is right — the stop guard manages its own separate state (relatedAgentIds, the background baseline, the queued-notification continuation flags) with its own trust lifecycle, so there's no behavioural coupling and leaving it alone is the correctly scoped choice. But the names invite someone to "unify" them later and break it. Worth a moment's thought; not worth a blocker.

I also considered whether the 4-line predicate should be shared between the two packages instead of written twice, and concluded no: the two sites operate on genuinely different part types (ACP's Part[] versus core's union, which needs the typeof guard), there's no existing helper to reuse, and extracting one would add cross-package surface for four lines. Following the existing duplication in the reminder machinery is the right call.

The post-fix flow, since this bug is entirely about when things fire relative to turns:

sequenceDiagram
    participant P1 as User
    participant P2 as Parent session (TUI or ACP)
    participant P3 as Config reminder state
    participant P4 as Foreground subagent
    P1->>P2: todo_write registers 6 nodes
    P2->>P3: setActiveTodoReminder (registered)
    P2->>P4: Agent tool call carrying todo_id
    Note over P4: tens of minutes of real work
    P4-->>P2: Agent tool result
    P2->>P3: takeActiveTodoReminder force=true (was budgeted, never came due)
    P3-->>P2: reminder text
    P2->>P1: spliced into next request, model updates plan
    P1->>P2: user turn asking about progress
    P2->>P3: getActiveTodoReminder(previous chain)
    P3-->>P2: still registered, so plan is unfinished
    P2->>P3: startActiveTodoWorkChain(new, continuedFrom previous)
    Note over P2,P3: chain and reminder survive (were cleared)
Loading

Testing

Evidence carried: the PR's own CI checks, read through the API. No PR code was built, run, or checked out — this is an unattended pull_request_target run, so the review is static and the test signal comes from the PR's isolated CI.

Nothing is red on this commit. The two gates that matter most for this diff — the Linux unit suite and lint/typecheck — were still in progress when I fetched, so I'm reporting them as pending rather than guessing. The SDK Java matrix and Real daemon E2E are green, which is the relevant signal for the ACP Session.ts half. 58 further checks are skipped as usual (conditional jobs, plus the tmux-testing and verify lanes, which nobody has triggered).

Worth naming explicitly: Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) are skipped on this PR, and you tested on Linux only — so platform coverage for this change is Linux-only, from both sides.

One housekeeping note, because it nearly produced a false finding: this runner is shared, and a concurrent review of a different PR (#10962, head 5a890810) overwrote the temp file my check-run fetch had landed in. A first read therefore showed a red Lint & Static (ubuntu-latest, Node 22.x) that belongs to #10962's ESLint step, not to this commit. Every row in the table below was re-fetched and confirmed to carry head_sha = 1a357350…; this PR has no failing check. Flagging it so nobody chases a lint failure that isn't yours.

Final CI results for 1a35735 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
macos-latest / Java 21 ✅ success
OpenTUI no-flicker gate ✅ success
Real daemon E2E / Java 11 ✅ success
Secret scan (TruffleHog) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

review-pr and triage in that table are bot orchestration jobs, not PR CI.

Not verified: the end-to-end claim. You say it yourself under Risk & Scope — no live multi-hour delegation session was run, and the original defect was only observable in one. The unit tests pin the mechanism convincingly (force flag passed, reminder present in the outgoing message), but they do so against a mocked config, so they cannot show that a real foreground subagent returning actually causes the injection, nor that the plan sidecar consequently stops freezing. A green suite also can't tell us the tests would go red without the fix.

Sandboxed verification would settle this, and since you have write access both lanes are open:

  • @qwen-code /verify — that the forced injection is actually load-bearing: an A/B against the base build would show the reminder reaching the model's next request at a real agent-result boundary, and confirm the new tests fail with the fix removed rather than passing either way.
  • @qwen-code /tmux — the user-visible half. The rendering is unchanged as you note, but a reminder arriving at a new moment is observable in a session with a plan and a delegation; this is also the only lane that would exercise the macOS/Windows gap CI leaves open.
中文说明

代码审查

没有致命阻塞项。 我没有只看 diff 就采信,而是逐个核实了它依赖的符号,机制是自洽的。

在本 commit 上核实的内容:

  • 调用点覆盖是完整的——这也是最容易出错的地方。 两个前端总共有七处 takeActiveTodoReminder 消费点,其中五处是轮次起始注入(Retry / Cron / Notification / Teammate,以及 ACP 的用户 prompt 路径),它们本来就force = true。真正按 3-turn 预算消费的只有本 PR 改的两处——core 的 ToolResult 路径与 ACP 的 #buildNextMessageAfterToolRun。所以既没有第三个前端仍被饿死,也没有已经强制的路径被重复强制。
  • 改动 (b) 所依赖的不变量是成立的。 refreshActiveTodoReminderunfinishedTodos.length === 0 时调用 setActiveTodoReminder(promptId, undefined),因此"提醒已注册"确实等价于"plan 仍有未完成项"。否则每个用户轮都续链就会永久泄漏陈旧上下文。
  • 没有漏掉 mock。 恰好有五个测试文件 mock 了这些 config 方法,五个都补上了 getActiveTodoReminder。少一个就会在调用时抛错——这种遗漏很容易犯,也很容易在 review 里看漏。
  • canonicalToolName 是有实际作用的,不是摆设——正是它让旧的 task 别名解析到 agent,而 it.each(['agent', 'task']) 把这点钉住了。它也与 ACP session 里已有的 function call 判定写法一致,读起来像周围的代码。
  • 测试断言的是结果,而不只是调用。 两个前端都检查提醒文本是否真的进入了发出的请求/消息,而不只是 takeActiveTodoReminder 收到了 true。ACP 那个测试还通过 session.prompt() 走了一次真实的 agent 工具往返。这才是"钉住行为"与"钉住函数签名"的区别。
  • 两处插入位置都正确:core 里 memory prompt 是追加的,所以谓词执行时 functionResponse 部分仍在最前,既有的 insertAt 逻辑本来就知道如何把提醒插在它们后面;ACP 里谓词作用于 toolRun.parts,且相对于 abortSignal.aborted 判断的位置与改动前一致。

三条非阻塞意见,按我希望维护者关注的程度递减:

  1. 改动 (b) 是用一种陈旧换另一种,且是有意为之。 已注册的提醒现在会活过之后每一个用户轮。该不变量保证 plan 未完成,但不保证它仍然相关——所以如果有人中途放弃 plan、转而做无关工作且不再调用 todo_write,旧 plan 就会在后续轮次和每次 agent 结果处继续提醒,而以前第一个新用户轮就把它清掉了。它是有界的(只有一个字符串;续链路径会删除其他 owner 的提醒,不会累积),这也正是 Todo plan state goes stale while work is delegated to subagents (the active-todo reminder never fires) #10953 要求的,模型也能自行清除。我不要求改动——只是把这个权衡记录下来,让它成为一个决定而非副作用。
  2. 后台 agent 的启动同样会触发强制注入。 谓词匹配任何顶层 Agent 工具结果,而后台启动会立即返回一个 ack——真正的进展稍后以完成通知的形式到达,那条路径本来就会强制注入。所以对后台委派而言,强制发生在启动时而非进展到达时。无害,但 Risk & Scope 里写的"每次完成的顶层委派最多一次提醒注入",读起来比实际行为更窄。
  3. ACP session 里相邻的两个标志现在含义不同。 #resetTodoStopGuardBackgroundLineage() 仍以 continuesCurrentWorkChain 为条件,而 todo 链续接用的是新的 continuesTodoWorkChain,两者相隔一行,处在一个 1.4 万行的文件里。我确认这是对的——stop guard 管理自己独立的状态(relatedAgentIds、后台基线、排队通知的续链标志)和独立的信任生命周期,因此没有行为耦合,不动它是范围正确的选择。但这两个名字容易诱导后人去"统一"它们从而改坏。值得想一想,不值得阻塞。

我也考虑过这 4 行谓词是否该在两个包之间共享而不是写两遍,结论是不必:两处操作的 part 类型确实不同(ACP 的 Part[] 对 core 的联合类型,后者需要 typeof 保护),也没有现成的辅助函数可复用,抽一个会为四行代码增加跨包对外面。沿用提醒机制里既有的重复是正确的选择。

修复后的流程如下,因为这个 bug 完全在于"何时"相对于轮次触发:(时序图见英文部分,此处不重复)

测试

所携证据:通过 API 读取的本 PR 自身 CI 检查结果。没有构建、运行或检出任何 PR 代码——这是一次无人值守的 pull_request_target 运行,审查是静态的,测试信号来自 PR 自己的隔离 CI。

本 commit 上没有红色项。对这个 diff 最重要的两道闸——Linux 单元测试与 lint/typecheck——在我抓取时仍在进行中,因此我如实报告为 pending,而不是猜测结果。SDK Java 矩阵与 Real daemon E2E 已绿,这对 ACP Session.ts 那一半是相关信号。另有 58 项按惯例被跳过(条件性任务,以及无人触发的 tmux-testingverify 通道)。

需要明确点出:本 PR 的 Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x) 均被跳过,而你也只在 Linux 上测过——所以这个改动的平台覆盖,从两边看都只有 Linux。

一条流程说明,因为它差点造成一个假发现:这台 runner 是共享的,一次针对另一个 PR(#10962,head 5a890810)的并行审查覆盖了我存放 check-run 结果的临时文件。因此第一次读取时出现了一个红色的 Lint & Static (ubuntu-latest, Node 22.x),它属于 #10962 的 ESLint 步骤,不属于本 commit。下表每一行都已重新抓取并确认携带 head_sha = 1a357350…;本 PR 没有失败的检查。特别说明,以免有人去追一个不属于你的 lint 失败。

(CI 表格见英文部分,机器可读区域由 finalize 任务在 CI 结束后就地更新。表中的 review-prtriage 是机器人编排任务,不是 PR CI。)

未验证:端到端结论。 你自己在 Risk & Scope 里也写了——没有跑过真实的数小时委派会话,而原始缺陷恰恰只能在那种会话里观测到。单元测试很有说服力地钉住了机制(传了 force 标志、提醒出现在发出的消息里),但它们是对 mock config 做的,因此无法证明真实的前台子 agent 返回确实触发了注入,也无法证明 plan sidecar 因此不再冻结。绿色的测试套件同样无法告诉我们:去掉修复后这些测试会不会变红。

沙箱验证可以解决这一点,而你有写权限,两条通道都开放:

  • @qwen-code /verify —— 验证强制注入是否真正承载功能:与 base 构建做 A/B,可以看到提醒在真实的 agent 结果边界进入模型的下一个请求,并确认新测试在移除修复后会失败,而不是两种情况都通过。
  • @qwen-code /tmux —— 用户可见的那一半。如你所说渲染没有变化,但提醒在一个新的时刻到达,在一个带 plan 和委派的会话里是可观测的;这也是唯一能覆盖 CI 留下的 macOS/Windows 缺口的通道。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 1a35735027581b99a44432bfa7490e73ada98539 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the mechanism is verifiably correct and the scope is genuinely minimal; the only thing keeping it off 5 is that the end-to-end behavioural claim is still unsettled, not that I doubt the code.

Stepping back: this is what a well-run bugfix looks like. You filed the issue with forensics rather than vibes, we verified the root cause against a named commit, you confirmed both delivery paths with red tests before writing the fix, and then the fix came in at 55 production lines that reuse machinery already in the tree. I wrote down what I'd have done before reading the diff — force the existing force flag at the agent-result boundary, pass continuedFrom on user turns, do it in both frontends — and that's what you did. The one place I'd have worried about a shortcut, hardcoding 'agent' and silently missing the legacy task alias, you'd already handled by routing through canonicalToolName and pinning it with it.each.

The checks I'd normally expect to fail on a change like this all came back clean. Coverage of the budgeted call sites is complete — there's no third frontend still starved, and no already-forced path got double-forced. The invariant change (b) depends on is actually enforced in todoWrite. All five config mocks were updated, not four. The tests assert the reminder reaches the outgoing message rather than just that a flag was passed.

What holds it at 4 rather than 5 is honest uncertainty about the thing the PR is for. The unit tests pin the mechanism against a mocked config; nothing yet shows a real foreground subagent returning and the plan sidecar consequently stopping its 55-minute freeze, and a green suite wouldn't tell us whether these tests go red without the fix. The Linux unit suite and lint were still running when I looked, and the macOS/Windows matrices are skipped on this PR against a Linux-only local run. That's a gap in evidence, not a defect I found — but I'd rather name it than paper over it, especially with acp-integration in the changed paths, which is where a green suite that doesn't pin the change costs the most.

My three notes from the review are all things I checked and concluded were not bugs: the reminder now outliving a pivoted-away plan is the tradeoff #10953 explicitly asked for and it's bounded; the background-launch force is harmless on a path that already forces; and the two adjacent continues* flags in the ACP session diverge on purpose, since the stop guard keeps its own separate state. If I were maintaining this in six months I'd be mildly annoyed by that third one and grateful for the rest.

So: approving, with the approval deferred until CI lands green on 1a35735027581b99a44432bfa7490e73ada98539. The unit suite and lint are still in flight and I'm not going to attest to a result that doesn't exist yet — the deferred approval fires only if everything on that commit completes green, and withholds itself if anything goes red or the head moves. If you want the behavioural gap closed rather than just the suite green, @qwen-code /verify is the lane that would do it.

中文说明

Confidence: 4/5 —— 机制经核实是正确的,范围也确实最小;没能给到 5 分的唯一原因是端到端的行为结论仍未落定,而不是我对代码本身有疑虑。

退一步看:这是一次做得很规范的 bugfix。你在 issue 里给的是取证信息而不是感觉,我们针对具名 commit 核实了根因,你在写修复之前先用红测试确认了两条投递路径,然后修复只用了 55 行生产代码,且复用了树里已有的机制。我在看 diff 之前先写下了自己会怎么做——在 agent 结果边界强制使用既有的 force 参数、在用户轮传入 continuedFrom、两个前端都改——而你正是这么做的。我唯一担心会走捷径的地方(硬编码 'agent' 从而悄悄漏掉旧的 task 别名),你已经通过走 canonicalToolName 处理掉了,并用 it.each 钉住。

这类改动上我通常预期会出问题的检查,全部干净通过。按预算消费的调用点覆盖是完整的——没有第三个前端仍被饿死,也没有已经强制注入的路径被重复强制。改动 (b) 依赖的不变量确实在 todoWrite 里被强制。五处 config mock 全部更新,而不是只更新了四处。测试断言的是提醒确实进入了发出的消息,而不只是某个标志被传了进去。

停在 4 分而不是 5 分,是对这个 PR 真正目的那份诚实的不确定。单元测试是对 mock config 钉住机制;目前还没有任何东西证明真实的前台子 agent 返回后 plan sidecar 就此停止它 55 分钟的冻结,而一套绿色的测试也无法告诉我们:去掉修复后这些测试会不会变红。我查看时 Linux 单元测试与 lint 仍在运行,而本 PR 的 macOS/Windows 矩阵被跳过,本地又只在 Linux 上跑过。这是证据上的缺口,不是我发现的缺陷——但我宁愿把它说出来,也不愿粉饰过去,尤其是改动路径里包含 acp-integration,那正是"套件绿了却没钉住改动"代价最高的地方。

我在审查中提的三条意见,都是经查证后判定不是 bug 的:提醒活过已被放弃的 plan,是 #10953 明确要求的权衡,且有界;后台启动触发的强制注入无害,因为那条路径本来就会强制注入;ACP session 里相邻的两个 continues* 标志是有意分开的,因为 stop guard 维护自己独立的状态。如果六个月后由我来维护这段代码,第三条会让我 mildly 恼火,其余的我会感激。

结论:批准,但批准推迟到 CI 在 1a35735027581b99a44432bfa7490e73ada98539 上全绿之后。单元测试套件与 lint 仍在进行中,我不会为一个尚不存在的结果背书——推迟的批准只会在该 commit 上所有检查全绿时才发出,若有红色项或 head 发生变动则自动撤回。如果你希望真正闭合行为上的缺口而不只是让套件变绿,@qwen-code /verify 是能做到这件事的通道。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 1a35735027581b99a44432bfa7490e73ada98539 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3391 to +3393
const continuedFrom =
this.activeTodoWorkChainPromptId !== undefined &&
this.config.getActiveTodoReminder(this.activeTodoWorkChainPromptId) !==

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: Once a reminder is registered, no ordinary user turn can start a fresh work chain again: continuation here is keyed only on getActiveTodoReminder(...) !== undefined, and startActiveTodoWorkChain with continuedFrom never clears that owner's reminder. An abandoned (never-completed) plan therefore pins its stale chain for the rest of the session — the only escapes left are a later todo_write or a session reset. Concretely: the user registers a 5-item plan, then pivots ("never mind, review PR Y instead") and the model never calls todo_write again. Pre-PR the next UserQuery cleared all reminders; post-PR every subsequent user turn continues the dead chain, the ACP turn-start force take (Session.ts:5699) now finds the reminder (pre-PR it found nothing) and injects "Keep the todo list current and continue the task" into turns about unrelated work, and every Agent delegation boundary re-injects it — steering the model back to abandoned work indefinitely. The mirror guard in Session.ts (continuesTodoWorkChain) has the same shape. Consider giving the subsystem an explicit abandonment decision instead of the reminder-presence proxy — e.g. expire continuation after N user turns without a todo_write refresh — while keeping the immediate follow-up turn continuing (the #10953 behavior). The bound must keep the continuation the new tests pin (client.test.ts asserts the follow-up turn calls startActiveTodoWorkChain('prompt-user-followup', 'prompt-userQuery'); Session.test.ts asserts ('test-session-id########2', 'test-session-id########1')). Acceptance: a new test that registers a reminder, runs user turns past the bound with no todo_write between, and asserts startActiveTodoWorkChain is called with undefined — removing the expiry must make it red.

Witness:

[probe] PR arm: takeActiveTodoReminder(id, true) returned the reminder on 4/4
abandoned turns (p2..p5, no todo_write in between); pre-PR arm (no
continuedFrom): undefined. 2/2 probe tests passed.
中文说明

一旦提醒被注册,普通用户轮次就再也无法开启新的工作链:此处的续链仅以 getActiveTodoReminder(...) !== undefined 为条件,而带 continuedFromstartActiveTodoWorkChain 从不清除该 owner 的提醒。因此被放弃(未完成)的计划会在会话余下时间里一直钉住其陈旧工作链——仅剩的退出方式是后续调用 todo_write 或重置会话。具体场景:用户注册了一个 5 项计划后转向其他任务("算了,改看 PR Y"),模型不再调用 todo_write。修复前,下一个 UserQuery 会清空所有提醒;修复后,之后每个用户轮都延续这条死链,ACP 轮首强制取用(Session.ts:5699)现在会找到该提醒(修复前找不到)并向无关工作的轮次注入"保持 todo 列表最新并继续任务",且每次 Agent 委派边界都会再次注入——无限期地把模型推回已放弃的工作。Session.ts 中的镜像守卫(continuesTodoWorkChain)同理。建议给子系统一个显式的"放弃"判定,而非以"提醒是否存在"作代理——例如在连续 N 个用户轮未调用 todo_write 后让续链过期——同时保留紧随其后的那一轮继续续链(即 #10953 要求的行为)。任何上界都必须保留新测试钉住的续链行为(client.test.ts 断言后续轮调用 startActiveTodoWorkChain('prompt-user-followup', 'prompt-userQuery');Session.test.ts 断言 ('test-session-id########2', 'test-session-id########1'))。验收标准:新增测试——注册提醒后,在不调用 todo_write 的情况下推进用户轮超过上界,断言 startActiveTodoWorkChainundefined 被调用;移除该过期机制后测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3391 to +3394
const continuedFrom =
this.activeTodoWorkChainPromptId !== undefined &&
this.config.getActiveTodoReminder(this.activeTodoWorkChainPromptId) !==
undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-4: The cleared-reminder branch of this new guard has no test in either frontend, so deleting the getActiveTodoReminder(...) !== undefined conjunct (here and in Session.ts's continuesTodoWorkChain) ships green: each test gets a fresh client (top-level beforeEach), the existing 'carries active todos…' pin runs while activeTodoWorkChainPromptId is still undefined, the two new continuation tests only exercise the registered state, and Session.test.ts's 'clears active todo context when an ordinary prompt starts' uses retry: true for its second prompt, which rides continuesCurrentWorkChain, not this guard. The behaviour that would then ship: once any chain has started, every ordinary user turn keeps continuing it even after the plan completed (todo_write deleted the reminder), so chain owners never reset. Extend the new continuation tests with a third step: reset getActiveTodoReminder to return undefined (simulating plan completion), send one more ordinary UserQuery/prompt, and assert startActiveTodoWorkChain was last called with (newPromptId, undefined) — in both client.test.ts and Session.test.ts. Acceptance: that third-step assertion itself — with the conjunct removed the follow-up turn passes the previous chain id instead of undefined, so it must go red.

Witness:

[probe] MUTANT (conjunct removed from client.ts:3391 and Session.ts:5582):
all todo tests green — 7 passed (core) + 143 passed (cli); mutation survives
the whole shipped suite.
Discriminating probe on the MUTANT FAILED: expected last spy call to have been
called with ['prompt-after-clear', undefined] — received
['prompt-after-clear', 'prompt-user-followup']; INTACT code: passed (402/402).
中文说明

这个新守卫的"提醒已清除"分支在两个前端都没有测试,因此删除 getActiveTodoReminder(...) !== undefined 这一条件(此处及 Session.tscontinuesTodoWorkChain)后所有测试仍然绿色:每个测试都使用全新的 client(顶层 beforeEach),既有的 'carries active todos…' 断言运行时 activeTodoWorkChainPromptId 还是 undefined,两个新的续链测试只覆盖"提醒已注册"状态,而 Session.test.ts 的 'clears active todo context when an ordinary prompt starts' 第二个 prompt 是 retry: true,走的是 continuesCurrentWorkChain 而非此守卫。若按此发布,行为将是:任何链一旦开始,即便计划已完成(todo_write 已删除提醒),每个普通用户轮仍会继续续链,链 owner 永远不会重置。建议在两个前端的新续链测试中追加第三步:将 getActiveTodoReminder 重置为返回 undefined(模拟计划完成),再发送一个普通 UserQuery/prompt,断言 startActiveTodoWorkChain 最后一次以 (newPromptId, undefined) 被调用。验收标准:即该第三步断言本身——删除上述条件后,后续轮会传入上一个链 id 而非 undefined,断言必须变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3931 to +3933
const activeTodoReminder = carriesAgentToolResult
? this.config.takeActiveTodoReminder(prompt_id, true)
: this.config.takeActiveTodoReminder(prompt_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-5: This new mid-turn use of force leaves its contract doc stale: takeActiveTodoReminder's docstring (packages/core/src/config/config.ts) still says "force is for turn-start injections (retry / related automatic turns)", while both frontends now pass force mid-turn at Agent-result boundaries (client.ts:3932 and Session.ts:7931). A maintainer refactoring per the documented contract could "restore" mid-turn takes to cadence on the doc's authority and regress #10953 precisely in the delegation-heavy sessions this PR targets. Extend the docstring with the third force case — e.g. "force is for turn-start injections (retry / related automatic turns) and for the mid-turn injection when a top-level Agent tool result returns — delegation advanced the plan while the parent earned only one tool turn, so the cadence cannot come due on its own." The forced take's counter reset (turns.set(owner, 0)) must stay documented alongside it.

Witness:

witness: not run — no execution capability settles a documentation-vs-code
contradiction; settled by direct comparison of committed text: docstring
"force is for turn-start injections (retry / related automatic turns)" vs
client.ts:3932 / Session.ts:7931 passing force mid-turn.
中文说明

force 的这一新用法(轮中注入)使其契约文档过时:takeActiveTodoReminder 的 docstring(packages/core/src/config/config.ts)仍写着 "force is for turn-start injections (retry / related automatic turns)",而两个前端现在都在 Agent 结果边界于轮中传入 force(client.ts:3932 与 Session.ts:7931)。维护者若按文档契约重构,可能依据文档"恢复"轮中取用的节奏限制,从而在本 PR 针对的委派密集会话中使 #10953 回归。建议扩展 docstring,补充第三种 force 场景——例如:"force 用于轮首注入(retry / 相关自动轮次),也用于顶层 Agent 工具结果返回时的轮中注入——委派推进了计划而父会话只产生了一个工具轮,节奏无法自然到期。"同时应一并保留对强制取用重置计数器(turns.set(owner, 0))的说明。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +2883 to +2885
mockToolRegistry.getTool.mockReturnValue({
name: 'agent',
kind: core.Kind.Execute,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: This force test only exercises the canonical 'agent' name (both the registry mock and the streamed functionCalls), but Session.ts:7927 calls canonicalToolName() specifically to also catch the legacy task alias — and nothing on the ACP side witnesses that alias (the twin TUI test pins both names via it.each(['agent', 'task'])). Dropping canonicalToolName() at Session.ts:7927 and comparing the raw name leaves the entire ACP suite green, while tool-result batches whose functionResponse carries the legacy name task — still supported per ToolNamesMigration — silently stop forcing the reminder due, re-freezing exactly the delegation-heavy sessions of #10953 on the ACP frontend. Parameterize the test like the core one: it.each(['agent', 'task'])('forces the active todo reminder due when a %s tool result returns', ...), feeding the name into both mockToolRegistry.getTool.mockReturnValue({ name: ... }) and the streamed chunk. The alias the parameterized case must use is task: ToolNames.AGENT in ToolNamesMigration (packages/core/src/tools/tool-names.ts). Acceptance: the 'task' case asserting takeActiveTodoReminder is called with (promptId, true) — removing canonicalToolName at Session.ts:7927 must make it fail.

Witness:

[probe] MUTANT (canonicalToolName dropped at the Session.ts force path):
existing ACP force test still passed (1/1) — mutation survives.
MUTANT + it.each(['agent','task']) fix: 'agent' passed, 'task' FAILED —
AssertionError: expected [] to include '<system-reminder>unfinished todo: fol…'
INTACT code restored: both cases green (2/2).
中文说明

该强制注入测试只覆盖了规范名 'agent'(registry mock 与流式 functionCalls 都是),但 Session.ts:7927 特意调用 canonicalToolName() 以同时捕获旧名 task 别名——而 ACP 侧没有任何测试见证该别名(TUI 的对应测试通过 it.each(['agent', 'task']) 钉住了两个名字)。若在 Session.ts:7927 去掉 canonicalToolName() 改为直接比较原始名字,整个 ACP 测试套件仍然全绿,而 functionResponse 携带旧名 task 的工具结果批次(ToolNamesMigration 仍然支持该名字)将不再强制提醒到期,在 ACP 前端重新冻结 #10953 所针对的委派密集会话。建议像 core 一样参数化该测试:it.each(['agent', 'task'])('forces the active todo reminder due when a %s tool result returns', ...),并将该名字同时传入 mockToolRegistry.getTool.mockReturnValue({ name: ... }) 与流式数据块。参数化用例必须使用的别名是 ToolNamesMigration(packages/core/src/tools/tool-names.ts)中的 task: ToolNames.AGENT。验收标准:'task' 用例断言 takeActiveTodoReminder(promptId, true) 被调用——移除 Session.ts:7927 的 canonicalToolName 后该断言必须失败。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +5580 to +5582
// continue that chain instead of discarding its context with the
// very turn that may be asking about it (#10953).
const continuesTodoWorkChain =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-5: This behaviour change leaves docs/design/active-todo-context.md — the committed design spec for this exact mechanism — describing the old semantics: it says the reminder is cleared when "a new ordinary work chain starts", and its Verification section asserts "An ordinary new prompt clears stale state while retry/continue retains it". After this diff an ordinary prompt RETAINS the chain whenever a reminder is registered (pinned by this PR's own new tests), and the doc's cadence paragraph omits the new forced injection at delegation boundaries. The next engineer debugging reminder lifecycle reads that doc — committed under docs/design/ precisely to be the reference — and investigates the wrong direction, e.g. files this continuation as a leak because the doc says it must clear. Update the design doc: replace the clear-on-ordinary-prompt rule with "an ordinary prompt continues the chain while a reminder is registered (unfinished items) and starts a fresh one otherwise", add the delegation-boundary force to the injection/cadence paragraphs, and fix the Verification bullet to match.

Witness:

witness: not run — no execution capability settles a documentation-vs-code
contradiction; settled by direct comparison of committed text:
docs/design/active-todo-context.md "Clear it when all todos complete, a new
ordinary work chain starts, or the session changes" and "An ordinary new
prompt clears stale state while retry/continue retains it" vs the new tests
pinning continuation on ordinary prompts in both packages.
中文说明

此行为变更使 docs/design/active-todo-context.md——正是该机制的已提交设计文档——仍在描述旧语义:文档说提醒会在"新的普通工作链启动时"被清除,其 Verification 一节断言"普通新 prompt 清除陈旧状态,而 retry/continue 保留"。本 diff 之后,只要提醒已注册,普通 prompt 就会保留工作链(由本 PR 自己的新测试钉住),且文档的节奏段落未提及委派边界的新增强制注入。下一位调试提醒生命周期的工程师阅读该文档(它被提交在 docs/design/ 下,正是作为权威参考),会朝错误方向排查——例如按文档"应当清除"的说法把新的续链行为当作泄漏上报。建议更新设计文档:将"普通 prompt 清除"规则改为"普通 prompt 在提醒已注册(仍有未完成项)时延续工作链,否则开启新链",在注入/节奏段落中补充委派边界强制注入,并同步修正 Verification 条目。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +7925 to +7929
const carriesAgentToolResult = toolRun.parts.some(
(part) =>
canonicalToolName(part.functionResponse?.name ?? '') ===
ToolNames.AGENT,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-8: On an ACP user turn with a registered reminder, the reminder is now injected twice into permanent history: the pre-existing turn-start force take (Session.ts:5698) resets the counter to 0 but does not delete the reminder, and this new agent-result force takes it again when the delegation returns — an identical second copy in the same turn. Pre-PR the second take was cadence-gated (elapsed = 1 < 3 → undefined), so such a turn stored one copy. This exceeds the PR's own Risk-section accounting ("at most one reminder injection per completed top-level delegation") and the cadence's documented purpose of bounding permanent history growth; core Retry/Cron/Notification/Teammate turns that delegate have the same double shape (client.ts:3842 + 3932). Note the new Session.test.ts force test already exhibits this: the first send receives the reminder via the turn-start force take, yet the test only asserts the last send contains it. Suppress the agent-result force when this turn already force-injected the reminder: capture whether the turn-start take returned a reminder and pass that into #buildNextMessageAfterToolRun so it only forces when it did not; mirror in client.ts for the Retry/Cron/Notification/Teammate turn-start branch. Dedup cannot key on the cadence counter: both the turn-start force take and setActiveTodoReminder reset turns to 0 (turns.set(owner, 0), ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3, config.ts:292); and the agent-result force must still fire for reminders registered mid-turn via todo_write — the property pinned by this PR's own force tests. Acceptance: with the reminder registered at turn start, assert the reminder text appears exactly once across the user-prompt send and the tool-result send combined; removing the dedup condition makes it appear twice → red.

Witness:

[probe] Intact PR code: PROBE_R1_8 copies_per_send=[1,1] total=2 sends=2
Agent-result force reverted (pre-PR cadence):
PROBE_R1_8 copies_per_send=[1,0] total=1 sends=2
中文说明

在提醒已注册的 ACP 用户轮中,提醒现在会被注入两次进永久历史:既有的轮首强制取用(Session.ts:5698)把计数器重置为 0 但并不删除提醒,而新增的 Agent 结果强制取用在委派返回时再次取用——同一轮内向历史追加一份完全相同的副本。修复前,第二次取用受节奏限制(elapsed = 1 < 3 → undefined),因此这类轮只存一份。这超出了本 PR Risk 一节自己的核算("每次完成的顶层委派至多一次提醒注入"),也超出了节奏机制文档中"约束永久历史增长"的设计目的;core 侧会委派的 Retry/Cron/Notification/Teammate 轮同样是双份形态(client.ts:3842 + 3932)。注意 Session.test.ts 的新增强制注入测试已经暴露了这一点:第一次发送经由轮首强制取用收到了提醒,而测试只断言最后一次发送包含它。建议:当本轮已经强制注入过提醒时,抑制 Agent 结果强制注入——记录轮首取用是否返回了提醒,并将其传入 #buildNextMessageAfterToolRun,仅在未注入过时强制;并在 client.ts 的 Retry/Cron/Notification/Teammate 轮首分支做镜像处理。去重不能以节奏计数器为键:轮首强制取用与 setActiveTodoReminder 都会把 turns 重置为 0(turns.set(owner, 0)ACTIVE_TODO_REMINDER_REFRESH_TURNS = 3,config.ts:292);且 Agent 结果强制注入必须对轮中经 todo_write 注册的提醒仍然生效——这是本 PR 自己的强制注入测试钉住的性质。验收标准:在轮首已注册提醒的情况下,断言提醒文本在"用户 prompt 发送"与"工具结果发送"合计仅出现一次;移除去重条件后出现两次 → 变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

wenshao and others added 2 commits September 4, 2026 09:00
…nges

The doc still promised that an ordinary new prompt clears stale Todo state,
and described the turn cadence as the reminder's only delivery path. This PR
changes both: an ordinary turn continues the chain while a reminder is
registered, and a tool-result batch carrying a top-level Agent result forces
the reminder due because a delegated run earns the parent a single turn.

Record the accepted cost (an abandoned plan resurfaces until a later write
completes or clears it) and that the ACP todo-stop-guard lineage reset stays
keyed to the retry/continue flag, so carrying a plan never widens guard trust.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Ran both rules end-to-end against real builds. Both behave as intended, so this can replace the Evidence (Before & After): N/A in the description.

Arms — one commit apart, so any difference is attributable to this change:

Arm Commit carriesAgentToolResult in built packages/core/dist
Before 661f41eef06 0
After 1a357350275 (this PR's fix commit; its parent is exactly the Before commit) 2

Both run as node <arm>/packages/cli/dist/index.js, both report 0.23.0. I deliberately did not use the branch head as the After arm, since it also carries a later merge of main (Web Shell branch picker, git-branches, SDK types) that is unrelated here.

Oracle caveat, worth knowing before anyone re-tests this. The session transcript cannot observe these injections. The reminder is appended to the outgoing request after that turn's tool-result record has already been written, so it never lands in the transcript as its own part — every transcript match for the reminder string is a tool's own output echoing it. I measured from the raw request payloads (--openai-logging) instead. One counting rule matters: an injection is a request where the reminder is the last message; the same literal sitting at a fixed earlier index in later requests is history replay of one earlier injection, not a new one.

Delegation boundary (headless; todo_write with three unfinished nodes → one foreground agent call → end of turn; both arms produced exactly that sequence):

Arm Requests carrying the reminder Carrier
Before 0 / 5
After 1 / 5 the agent tool result

The whole delta is one message: Before's final request has 6 messages ending at the agent tool result, After's has 7 — identical through msg[5], plus the reminder as a trailing role=user message listing the three unfinished nodes.

Same run without todo_id on the Agent call: Before 0 / 5, After 1 / 5. Confirms the trigger keys on the Agent tool result rather than on todo_id, which is what the description claims.

User turn keeps an unfinished plan (interactive tmux, two user turns; both arms produced the identical sequence todo_write → echo one → echo two → echo three, 15 requests each, nothing marked completed, no stray todo_write in turn 2):

Arm Turn 1 Turn 2
Before 0 0
After 0 1 fresh injection, plus 2 later requests replaying it at a fixed index

The fresh one lands on turn 2's first tool result (echo two), as the request's last message — exactly where the continued chain should put it. Turn 1 is 0 in both arms, which is the cadence behaving normally.

Independent corroboration that the After arm really delivered it: at the identical point in the headless scenario the model's own reasoning differs by arm. Before: "There's no actual task or question yet — just setup… A short acknowledgment is the right move." After: "The system reminder is pushing me to update todos, but the user's explicit instruction takes priority here." It can only object to a reminder it received.

Not covered here: the ACP frontend half (packages/cli/src/acp-integration/session/Session.ts). Exercising it needs a daemon plus an ACP/Web Shell client, so only the TUI/core path is verified end to end — worth not claiming both frontends were E2E-verified. The ACP half remains covered by the unit tests in this PR.

Also pushed 8230d170737 to this branch: a docs/design/active-todo-context.md sync. The doc still promised that an ordinary new prompt clears stale Todo state and described the turn cadence as the reminder's only delivery path, and this PR changes both. That commit also records the accepted cost (an abandoned plan resurfaces until a later todo_write completes or clears it) and that the ACP todo-stop-guard lineage reset stays keyed to the retry/continue flag, so carrying a plan never widens guard trust.

Non-blocking: #10953 originally measured this from transcripts and got it wrong — it reported 0 injections, which that oracle cannot actually establish. Corrected there with the reason, so the invalid oracle doesn't get reused.

中文

两条规则都在真实构建上端到端跑过了,行为符合预期,可以用来替换描述里的 Evidence (Before & After): N/A

两臂——只差一个 commit,所以任何差异都可归因于本改动:

Commit 构建产物 packages/core/distcarriesAgentToolResult
Before 661f41eef06 0
After 1a357350275(本 PR 的 fix commit,其父提交正好是 Before) 2

两臂都以 node <arm>/packages/cli/dist/index.js 运行,都报 0.23.0。我特意没拿分支 head 当 After 臂,因为它还带着后续合进来的 main(Web Shell branch picker、git-branches、SDK types),与此处无关。

口径提醒,别人复测前值得知道。 会话 transcript 观测不到这些注入。提醒是在该轮 tool-result 记录写盘之后才追加到出站请求上的,所以它根本不会作为独立 part 落进 transcript——transcript 里每一次命中该字符串,都是某个工具自己输出的回声。我改用原始请求体(--openai-logging)来测。有一条计数规则很关键:注入指的是提醒作为该请求最后一条消息出现;同一字面量在后续请求里固定在较早的 index 上,是那一次注入的历史重放,不是新注入。

委派边界(headless;todo_write 写三个未完成节点 → 一次前台 agent 调用 → 结束该轮;两臂都精确产出了这个序列):

携带提醒的请求数 携带者
Before 0 / 5
After 1 / 5 agent 的 tool result

整个差异就是一条消息:Before 的最后一个请求有 6 条消息、止于 agent 的 tool result;After 有 7 条——到 msg[5] 完全相同,多出一条尾部的 role=user 提醒,列出三个未完成节点。

同样的流程不带 todo_id 再跑一次:Before 0 / 5,After 1 / 5。确认触发条件是 Agent 的 tool result 而非 todo_id,与描述一致。

用户轮次保留未完成 plan(交互式 tmux,两轮用户输入;两臂产出完全相同的序列 todo_write → echo one → echo two → echo three,各 15 个请求,没有任何节点被标记完成,turn 2 里也没有多余的 todo_write):

Turn 1 Turn 2
Before 0 0
After 0 1 次真注入,另有 2 个后续请求在固定 index 上重放它

真注入落在 turn 2 的第一个 tool result(echo two)上,作为该请求的最后一条消息——正是续链后应该出现的位置。Turn 1 两臂都是 0,这是节奏机制正常工作的表现。

另一条独立佐证,说明 After 臂确实送达了:在 headless 场景的同一位置上,模型自己的推理因臂而异。Before:"There's no actual task or question yet — just setup… A short acknowledgment is the right move." After:"The system reminder is pushing me to update todos, but the user's explicit instruction takes priority here." 它只能对自己收到的提醒表示异议。

**本次未覆盖:**ACP 前端那一半(packages/cli/src/acp-integration/session/Session.ts)。要跑它需要 daemon 加 ACP/Web Shell 客户端,所以只有 TUI/core 路径是端到端验证过的——不宜声称两个前端都做了 E2E 验证。ACP 那一半仍由本 PR 的单测覆盖。

另外往本分支推了 8230d170737:同步 docs/design/active-todo-context.md。文档原先仍写着普通新 prompt 会清掉陈旧 Todo 状态,并把 turn 节奏描述为提醒唯一的送达路径,而本 PR 把这两条都改了。该 commit 同时记录了已接受的代价(被弃置的 plan 会持续复现,直到某次 todo_write 完成或清空它),以及 ACP 侧 todo-stop-guard 的 lineage reset 仍只挂在 retry/continue 标志上,因此续链不会放宽 guard 的信任。

非阻塞:#10953 最初用 transcript 度量这件事,测错了——它报告注入 0 次,而那个口径根本无法证明这一点。已在那边更正并说明原因,避免这个无效口径被再次复用。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

CI attribution: the Dependency CVE audit red is not from this diff.

What actually fails is the per-package loop inside Audit production dependencies, at the packages/live-host iteration — npm's audit call gets a 400 back from the registry:

npm warn audit 400 Bad Request - POST https://registry.npmjs.org/-/npm/v1/security/audits/quick - Bad Request
  message: 'Invalid package tree, run  npm install  to rebuild your package-lock.json'
npm error audit endpoint returned an error

The same log also carries npm's own This endpoint is being retired. Use the bulk advisory endpoint instead.

Three things pin this to the registry rather than to this branch:

1. The advisories are not what turns the step red. The root audit reports diff 6.0.0 - 8.0.2 (GHSA-73rr-hh4g-fpgx, low) and uuid <11.1.1 (GHSA-w5hq-g745-h8pq, moderate) — 2 vulnerabilities (1 low, 1 moderate), both below this step's --audit-level=high gate. They show up identically in passing runs, so they never set the exit status.

2. This branch changes no dependency input. Eight files, all docs / TS source / tests — no manifest, no lockfile.

3. The same sub-audit passed two minutes earlier on byte-identical input. packages/live-host/package.json (blob 152bd4f0a42e) and packages/live-host/package-lock.json (blob cfea05122791) are the same blobs at this head and at #10945's head, and neither branch touches them.

Run Audit step live-host iteration (added 336 packages) Result
#10945 — job 100894183912 02:53:20 → 02:57:23 found 0 vulnerabilities success
this PR — job 100894571938 02:55:26 → 03:01:59 HTTP 400 Invalid package tree failure

Same input, same window, different outcome — so this is a re-run, not a fix. If it keeps recurring across PRs, the durable fix belongs in the workflow itself (move to the bulk advisory endpoint, or retry on audit endpoint returned an error), not in this branch.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

The Dependency CVE audit red on this branch is not from 8230d170737, which only touches docs/design/active-todo-context.md.

The finding is the jsdiff DoS in diff (GHSA-73rr-hh4g-fpgx, affected range 6.0.0–8.0.2, fix is diff@9.0.0 which is breaking). This branch's lockfile pins node_modules/diff -> 7.0.0, and main's pins the exact same version — identical entries, including the nested 8.0.4 / 8.0.3 / 9.0.0 copies. Main's Security Checks run at 02:51:22Z was green and this branch's at 02:55:27Z was red, so the same dependency set produced both outcomes four minutes apart; the audit result moved, not the branch.

chore/cve-audit-bumps-10850 ("clear remaining CVE audit findings (diff DoS, …)") is already clearing this and was green at 03:01:14Z. Nothing to fix here — merging that bump or a re-run should settle it.

中文

本分支上 Dependency CVE audit 的红不是 8230d170737 造成的,那个 commit 只动了 docs/design/active-todo-context.md

命中的是 diff 的 jsdiff DoS(GHSA-73rr-hh4g-fpgx,受影响区间 6.0.0–8.0.2,修复要 diff@9.0.0,属 breaking)。本分支 lockfile 里是 node_modules/diff -> 7.0.0,main 里逐字相同——包括嵌套的 8.0.4 / 8.0.3 / 9.0.0 那几份。main 的 Security Checks02:51:22Z 是绿的,本分支在 02:55:27Z 是红的,同一套依赖在四分钟内给出两种结果,说明变的是 audit 结果,不是分支。

chore/cve-audit-bumps-10850("clear remaining CVE audit findings (diff DoS, …)")已经在清这个 finding,03:01:14Z 是绿的。这里没什么要改的——合入那个 bump 或重跑一次应该就好了。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

6 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 abandoned-plan chain never expires (continuation keyed only on reminder presence) — still stands, already reported (comment 3928922276)
  • R1-3 ACP force test only exercises 'agent', not the legacy 'task' alias — still stands, already reported (comment 3928922296)
  • R1-4 cleared-reminder branch of the continuation guard has no test in either frontend — still stands, already reported (comment 3928922285)
  • R1-5 takeActiveTodoReminder 'force' docstring stale after the new mid-turn use — still stands, already reported (comment 3928922289)
  • R1-8 reminder injected twice into permanent history on an ACP user turn with a registered reminder — still stands, already reported (comment 3928922307)
  • R2-1 TUI/ACP continued-user-turn injection asymmetry (probe-confirmed) — dropped as overlap with the R1-1/R1-4 threads at packages/core/src/core/client.ts:3393-3394 (comments 3928922276, 3928922285); full finding in the findings artifact an…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 1 by the review time budget.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/core/src/core/client.ts:3924 (+3 locations) — [review] Both new reminder-policy rules are re-derived inline in both frontends instead of owned once in Config/core — one-sided future edits ship silent TUI/ACP divergence
  • packages/core/src/core/client.ts:3924 — [review] No logging at any of the four new reminder-decision sites while adjacent mechanisms in the same functions log their decisions
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 6 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查:反向审计——评审时间预算不足,未能开始第 1 轮。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

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

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants