Skip to content

feat(goal): pause a Goal after three autonomous turns that make no progress - #11239

Merged
qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:feat/goal-no-progress-pause
Sep 8, 2026
Merged

feat(goal): pause a Goal after three autonomous turns that make no progress#11239
qqqys merged 7 commits into
QwenLM:mainfrom
qqqys:feat/goal-no-progress-pause

Conversation

@qqqys

@qqqys qqqys commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

A Goal now stops itself when its autonomous turns stop producing anything to judge. After three consecutive runtime-driven turns that recorded no evidence-bearing tool result and proposed no terminal state, the Goal pauses and says so, and the user resumes it or edits the objective.

Only the runtime's own continuations are measured, identified by the same host delivery mark that already decides whether an objective announcement was delivered. A turn carrying the user's own text is the user steering, and it restarts the streak; so does a user turn reserved while the third quiet turn was still running, which is served rather than stopped in front of. The wind-down hand-off turn is exempt from both halves, because it is asked to hand off rather than to work. get_goal and update_goal results do not count as progress: they carry provenance: 'goal_runtime', and a turn that only re-reads its own state is the clearest form of the idling this bound exists to notice.

The streak is persisted on the Goal record the same way the checkpoint stall streak is, so a daemon restart or a session resume cannot launder it, and zero is spelled as no field. Edit, replace, and every resume clear it, including the resume of a Goal this bound stopped: resuming is the user asking for another run at the objective, and starting that run three-quarters of the way to the bound would end it after a single quiet turn.

The count comes from a new optional accessor on the turn ledger, fed by the chat recorder where tool results are already stamped with the Goal permit. Optional is load-bearing rather than cosmetic: a ledger that cannot answer, or that throws, switches the bound off for the session instead of reporting every turn as idle. "Nothing measured" is not "nothing happened", and the failure mode of getting that backwards is stopping a Goal that was working. For the same reason the bound tests the count measured on the finishing turn rather than the count read off the record.

The stop is a pause rather than a blocked or usage-limited state. blocked is a verdict the verifier reaches on cited evidence, and this bound has no evidence to cite; usage_limited says an allowance was spent, and nothing was. Following the pause-reasons design already in the tree, no new Goal state cause is introduced, and the stop renders through the paused-Goal path every surface already has: the /goal card, the ACP goal state update, and the headless goal state event all carry the reason with no per-host work, and in the TUI the runtime's broadcast arrives as the goal-state stream event the transcript already renders. One host branch did need widening. The interactive path that renders a terminal card right after a turn ended by a turn-terminating tool listed complete, blocked and usage_limited only, so a Goal this bound paused at the end of an update_goal turn finished silently there; it now includes paused, mapped to the pause cause the card is keyed to. The reason is host-neutral ("Three Goal turns in a row recorded nothing to judge and no proposal. Resume the Goal to try again, or edit its objective into one the model can act on and then resume it."), since a headless process has exited by the time anyone reads it.

The bound yields to the limits that describe the Goal better on the turn where they coincide with it: a spent token budget still reaches the continuation gate, which grants the wind-down hand-off and stops as usage_limited/token_budget; a Goal carrying a checkpoint stall streak lets its checkpoint run, so the stall breaker stops it with the evidence-catalog reason instead of a pause whose remedy would resume it into the same overflowing window. A user turn reserved while the pause record is being written is served rather than stopped in front of, exactly as one reserved earlier; the journal may then hold a pause record the runtime never adopted, the same shape the budget stop already leaves when its post-write re-validation fails.

One host-side projection changes: the ACP transcript replay used to replay every pause record as the user typing /goal pause. This is the first pause the runtime writes with nobody at the keyboard, so a pause now replays as a user message only when its reason says the user typed it, or when the record predates pause reasons; every other pause replays as the paused card alone, which carries the reason.

Why it's needed

An autonomous Goal had two bounds and neither one noticed a Goal going in circles.

The token budget is the only thing the continuation gate stops for, and it defaults to 30,000,000 tokens. The checkpoint stall streak measures the opposite problem: it fires when a Goal produces too much evidence to catalogue, and a Goal producing none never advances it at all — there is an existing test asserting exactly that, "keeps the stall streak when a turn records no evidence at all". So a model that answers each turn with a paragraph of status and calls no tools kept being continued until the budget ran out, with nothing on the record that could ever end the loop, while the user watched the turn count and the spend climb.

Both comparable runtimes bound this. Codex stops a Goal after three consecutive turns whose only tool activity was failed execs. Claude Code caps consecutive Stop-hook blocks at eight.

The threshold here is a constant rather than a setting, and three matches the checkpoint stall bound and the blocked-audit streak already in this file family. A configurable goals.* family belongs with the separate work on turn and time budgets.

Reviewer Test Plan

How to verify

The behaviour is runtime-side and fully covered by unit tests against the real runtime, reducer, and recorder. The interesting cases are the ones where the bound must not fire.

cd packages/core && npx vitest run src/goals/goal-runtime.test.ts src/goals/goal-reducer.test.ts src/goals/goal-protocol.test.ts src/services/chatRecordingService.test.ts src/config/config.test.ts
 ✓ src/goals/goal-runtime.test.ts (165 tests) 2102ms
 ✓ src/goals/goal-reducer.test.ts (106 tests) 165ms
 ✓ src/goals/goal-protocol.test.ts (8 tests) 22ms
 ✓ src/services/chatRecordingService.test.ts (125 tests) 843ms
 ✓ src/config/config.test.ts (623 tests) 7602ms

 Test Files  5 passed (5)
      Tests  1027 passed (1027)

The seventeen runtime cases are grouped under "no-progress bound". Worth reading rather than just running: a ledger that cannot count, one that throws, and one that answers with something that is not a count all leave the bound switched off; a restored streak is only spent by a turn the ledger could actually measure; a waiting user turn outranks the bound, including one reserved while the pause record was being written; a spent token budget and an overflowing evidence window each stop the Goal with their own reason instead; the turn that spends the streak runs no checkpoint; and a failed settle write still shows the stop rather than stranding an active Goal nothing will continue.

To exercise it interactively, set an objective the model can only answer in prose and cannot complete on its own, for example: report your status in one sentence each turn without running any tool, and treat the objective as complete only after the user types a given word. Three autonomous turns later the Goal pauses with the new reason, and /goal resume gives it the whole allowance again rather than its last turn.

Evidence (Before & After)

No new UI. The paused card and /goal output are the existing ones that already render every other pause reason, so there is nothing visually new to screenshot. The evidence is the Goal state stream, run headless against a live model with the same objective on both sides.

Before — released 0.22.3. The Goal never stops. Killed at a five-minute wall clock, not by anything in the runtime:

$ qwen -p '/goal set Report your status in one sentence each turn and do not run any tool. The objective is complete only after the user types the word release.' --output-format stream-json

turnCount reached: 65
statuses seen:     259 x "active"     (no other status)
tokensUsed:        2,124,407 in 278s of active time
noProgressTurns:   field absent

After — this branch, rerun on the head commit. The same objective stops itself and the run exits result success, is_error: false:

turn | status  | noProgressTurns | tokensUsed
   1 | active  | 1               |  20,251
   2 | active  | 2               |  41,044
   3 | active  | -               |  86,634
   4 | active  | 1               | 110,495
   5 | active  | 2               | 134,677
   6 | active  | -               | 214,177
 7-11 | active | -               | 594,252
  12 | active  | 1               | 637,883
  13 | active  | 2               | 681,843
  14 | paused  | 3               | 726,310

statuses seen:  45 x "active", 1 x "paused"     tool calls: get_goal x7, update_goal x7
lastReason:     "Three Goal turns in a row recorded nothing to judge and no proposal.
                 Resume the Goal to try again, or edit its objective into one
                 the model can act on and then resume it."

This run is more informative than a clean three-and-stop, so it is the one reported. The model called no tool but update_goal on seven turns, and every one of those restarted the streak — a terminal proposal is something to judge, even when the verifier rejects it. Turns 12 to 14 are the first three consecutive turns that proposed nothing, and the bound fires on the third. So the bound is a floor under turns that produce nothing at all, not under a model that keeps proposing; that shape is still bounded only by the token budget, and belongs with the separate work on turn and time budgets.

A Goal that is working is untouched. Same branch, an objective the model can only satisfy by reading files:

/goal set Read every .txt file in the current directory with your file tools and
report the exact contents of each one. Complete only when all three files have
been read and quoted.

turnCount:        1
statuses seen:    3 x "active", 2 x "complete"
noProgressTurns:  field never appears
lastReason:       verifier acceptance, citing the read_file results

The objective, the steps and the raw figures above are all in this description; the working notes live outside the repo, so there is nothing further to open.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

Linux, npm run build then scripts/start.js, live model, headless stream-json output. Baseline run against the globally installed 0.22.3.

Risk & Scope

  • Main risk or tradeoff: a Goal that was working could be stopped if the progress signal were wrong. The signal is deliberately conservative in that direction — an unmeasurable turn, an unmeasured session, a user-driven turn, a hand-off turn, and a turn with any tool result or proposal all leave the Goal running, and the only way to trip the bound is three consecutive runtime-driven turns that recorded nothing at all. The stop is also the most recoverable one available: /goal resume restores the full allowance.
  • Not validated / out of scope: /goal resume and the mid-Goal user message were exercised in unit tests but not in the live headless runs above, which end when the Goal stops. Token accounting scope, budget display, and configurable turn or time budgets are separate work.
  • Breaking changes / migration notes: none for users. The persisted record gains an optional field, and a Goal persisted before it existed restores with no streak. The exported turn ledger interface is renamed from GoalTurnTokenLedger to GoalTurnLedger and its option key from tokenLedger to ledger, now that it carries both what a turn spent and what it produced; nothing outside the runtime referenced either name.

Linked Issues

Part of #4228.

中文说明

这个 PR 做了什么

现在,当一个 Goal 的自主轮不再产出任何可供判定的东西时,它会自己停下来。连续三个由运行时驱动的轮次既没有记录可作证据的工具结果、也没有提出终态提案,Goal 就会 pause 并说明原因,由用户决定是恢复还是修改目标。

只有运行时自己发起的续跑轮会被计入,判据是 host 的送达标记——也就是现在已经用来判断目标变更通知是否送达的那个信号。承载用户自己文字的轮次是用户在把方向,它会让计数从头开始;在第三个空转轮还在跑时被用户预约的轮次也一样,它会被正常服务,而不是被挡在停机前面。预算 wind-down 的交接轮对两边都豁免,因为它被要求的是交接而不是干活。get_goalupdate_goal 的结果不算进展:它们的 provenance 是 goal_runtime,而一个只反复读自己状态的轮次,正是这道闸要拦的空转最典型的样子。

计数像 checkpoint 连停计数一样持久化在 Goal 记录上,这样守护进程重启或会话恢复都洗不掉它,并且 0 以"没有这个字段"来表达。edit、replace 和每一次 resume 都会清零,包括对被这道闸停下的 Goal 的 resume:恢复是用户要求对这个目标再跑一轮,如果从离上限只差四分之一的地方起跑,一个空转轮就会再次停机。

计数来自轮次 ledger 上新增的一个可选方法,由聊天记录器在记录工具结果时喂给它——那些结果本来就已经打上了 Goal permit。"可选"在这里是有承重作用的,不是装饰:一个答不上来或者抛错的 ledger 会让这道闸在整个会话里关闭,而不是把每一轮都报成空转。"没测到"不等于"什么都没发生",把这两者搞反的后果是停掉一个正在干活的 Goal。出于同样的理由,这道闸判断的是本轮实测到的计数,而不是记录上读到的计数。

这次停机是 pause,不是 blocked 也不是 usage_limited。blocked 是 verifier 基于被引用的证据做出的判定,而这道闸恰恰没有证据可引;usage_limited 表示某项额度被花光了,而这里什么都没花掉。沿用树上已有的 pause 原因设计,本 PR 不新增 GoalStateCause,停机走的是各界面本来就有的 paused Goal 渲染路径:/goal 卡片、ACP 的 goal state 更新、headless 的 goal state 事件都能直接带上原因,无需逐个 host 改;TUI 这边运行时的广播会以 goal-state 流事件到达,而那条路本来就会渲染。有一处 host 分支确实需要放宽:交互路径上「由终止回合的工具结束的那一轮」结束后渲染终态卡片的判断只列了 completeblockedusage_limited,所以被这道闸在 update_goal 轮末停掉的 Goal 在那里是无声的;现在把 paused 也纳入,并映射成卡片所依据的 pause cause。原因文案是宿主中立的("Three Goal turns in a row recorded nothing to judge and no proposal. Resume the Goal to try again, or edit its objective into one the model can act on and then resume it."),因为 headless 进程在有人读到它之前就已经退出了。

在同一轮里与其他限制重合时,这道闸让位给更能描述该 Goal 的那道:花光 token 预算的轮次仍会走到续跑闸,由它授予 wind-down 交接轮并以 usage_limited/token_budget 停机;记录上带 checkpoint 连停计数的 Goal 会让 checkpoint 先跑,由熔断以证据目录的原因停机,而不是给出一个 resume 后会重新进入同一个溢出窗口的 pause。在 pause 记录落盘期间被预约的用户轮同样会被服务而不是被挡在停机前面;此时 journal 里可能留下一条运行时未采纳的 pause 记录,这和预算停机在写入后复验失败时留下的形状一致。

有一处 host 侧的投影随之改动:ACP transcript 回放原本把每条 pause 记录都回放成用户敲了 /goal pause。这是运行时第一次在无人在场时写 pause,所以现在只有原因表明是用户敲的(或记录早于 pause 原因)时才回放成用户消息;其他 pause 只回放带原因的 paused 卡片。

为什么需要

自主 Goal 原本有两道闸,但没有一道能发现 Goal 在原地打转。

续跑闸唯一会为之停下的是 token 预算,默认 30,000,000。checkpoint 连停计数量的是相反的问题:它在 Goal 产出的证据多到装不下时才触发,而一个完全不产出证据的 Goal 根本推不动它——树上就有一个用例断言了这一点,"keeps the stall streak when a turn records no evidence at all"。于是一个每轮只回一段状态、不调任何工具的模型会一直被续跑到预算耗尽,记录上没有任何东西能结束这个循环,而用户只能看着轮数和花费一起往上爬。

两个对照实现都设了这道闸。Codex 在连续三轮只有失败的 exec、没有成功工具调用时把 Goal 置为 Blocked。Claude Code 把连续的 Stop-hook 拦截次数上限设为 8。

这里的阈值是常量而不是设置项,3 与本文件家族里已有的 checkpoint 连停上限和 blocked 审计连续数一致。可配置的 goals.* 设置族属于轮数与时间预算那一项单独的工作。

评审验证方式

如何验证

这个行为在运行时侧,由针对真实 runtime、reducer 和 recorder 的单测完整覆盖。真正值得看的是那些这道闸不该触发的用例。

cd packages/core && npx vitest run src/goals/goal-runtime.test.ts src/goals/goal-reducer.test.ts src/goals/goal-protocol.test.ts src/services/chatRecordingService.test.ts src/config/config.test.ts
 ✓ src/goals/goal-runtime.test.ts (165 tests) 2102ms
 ✓ src/goals/goal-reducer.test.ts (106 tests) 165ms
 ✓ src/goals/goal-protocol.test.ts (8 tests) 22ms
 ✓ src/services/chatRecordingService.test.ts (125 tests) 843ms
 ✓ src/config/config.test.ts (623 tests) 7602ms

 Test Files  5 passed (5)
      Tests  1027 passed (1027)

17 个 runtime 用例归在 "no-progress bound" 下。值得读而不只是跑:数不了工具结果的 ledger、会抛错的 ledger、以及返回值不是一个计数的 ledger,都会让这道闸保持关闭;被恢复出来的计数只会被 ledger 真正测量得到的那一轮花掉;等待中的用户轮优先于这道闸,包括在 pause 记录写入过程中才登记的那一个;token 预算花光和证据窗口溢出各自以自己的原因停掉 Goal;花掉计数的那一轮不跑 checkpoint;落盘失败时仍然显示停机,而不是留下一个没人会去续跑的 active Goal。

要交互式地体验,可以设一个模型只能用文字回答、且自己无法完成的目标,例如:每轮用一句话报告状态、不许调用任何工具,并且只有当用户输入某个指定词时才算完成。三个自主轮之后 Goal 就会带着新原因 pause,而 /goal resume 会把整份额度还给它,而不是只还最后一轮。

证据(Before & After)

没有新 UI。paused 卡片和 /goal 输出都是现成的、已经在渲染其它所有 pause 原因的那些,没有新的视觉内容可以截图。证据是 Goal 状态流,两边用同一个目标、对真实模型跑 headless。

改动前 —— 已发布的 0.22.3。 Goal 永远不停。是被五分钟的墙钟掐掉的,不是被运行时里的任何东西停下的:

$ qwen -p '/goal set Report your status in one sentence each turn and do not run any tool. The objective is complete only after the user types the word release.' --output-format stream-json

轮数达到:        65
出现过的状态:    259 次 "active"(没有别的状态)
tokensUsed:      2,124,407,活跃时长 278 秒
noProgressTurns: 字段不存在

改动后 —— 本分支,在 head 提交上重跑。 同一个目标自己停了下来,进程以 result successis_error: false 退出:

turn | status  | noProgressTurns | tokensUsed
   1 | active  | 1               |  20,251
   2 | active  | 2               |  41,044
   3 | active  | -               |  86,634
   4 | active  | 1               | 110,495
   5 | active  | 2               | 134,677
   6 | active  | -               | 214,177
 7-11 | active | -               | 594,252
  12 | active  | 1               | 637,883
  13 | active  | 2               | 681,843
  14 | paused  | 3               | 726,310

出现过的状态:  45 次 "active",1 次 "paused"     工具调用: get_goal x7、update_goal x7
lastReason:    "Three Goal turns in a row recorded nothing to judge and no proposal.
                Resume the Goal to try again, or edit its objective into one
                the model can act on and then resume it."

这次运行比"干净地三轮就停"更有信息量,所以报告的是它。模型除了 update_goal 之外没调用任何工具,而那七轮每一轮都让计数重新开始——终态提案是"可判断的东西",哪怕 verifier 把它驳回。第 12 到 14 轮是头三个连续什么都没提案的轮次,这道闸在第三轮触发。所以它是"完全没有产出的轮次"的下限,不是"一直在提案的模型"的下限;后者目前仍然只被 token 预算兜底,属于轮数与时间预算那部分单独的工作。

正在干活的 Goal 不受影响。 同一分支,一个模型只能靠读文件才能满足的目标:

/goal set Read every .txt file in the current directory with your file tools and
report the exact contents of each one. Complete only when all three files have
been read and quoted.

轮数:             1
出现过的状态:     3 次 "active",2 次 "complete"
noProgressTurns:  字段从未出现
lastReason:       verifier 的接受理由,引用了 read_file 的结果

目标、步骤和上面这些原始数字都在本描述里;工作笔记不在仓库内,没有别的东西需要打开。

测试平台

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

Linux,npm run build 后用 scripts/start.js,真实模型,headless stream-json 输出。基线用全局安装的 0.22.3 跑。

风险与范围

  • 主要风险或权衡:如果进展信号判断错了,一个正在干活的 Goal 可能被停掉。这个信号在这个方向上是刻意保守的——测不到的轮次、整个会话都测不了的情况、用户驱动的轮次、交接轮,以及任何有工具结果或提案的轮次,都会让 Goal 继续跑;唯一能触发这道闸的,是连续三个什么都没记录的运行时驱动轮。而且这次停机也是可选项里最好恢复的一种:/goal resume 会还回完整额度。
  • 未验证 / 范围外:/goal resume 与 Goal 进行中插入用户消息这两条路径有单测覆盖,但没有在上面的真实 headless 运行里走一遍——那些运行在 Goal 停机时就结束了。token 记账口径、预算展示,以及可配置的轮数或时间预算都是单独的工作。
  • 破坏性变更 / 迁移说明:对用户没有。持久化记录新增一个可选字段,在该字段出现之前持久化的 Goal 恢复时不带任何计数。导出的轮次 ledger 接口从 GoalTurnTokenLedger 更名为 GoalTurnLedger,选项键从 tokenLedger 更名为 ledger,因为它现在同时承载一轮花了多少和产出了什么;运行时之外没有任何地方引用过这两个名字。

关联 Issue

属于 #4228 的一部分。

…ogress

An autonomous Goal had two bounds and neither noticed one going in circles.
The token budget is the only thing `queueContinuation` stops for, 30M tokens
by default, and the checkpoint stall streak measures the opposite problem: it
fires when a Goal produces too much evidence to catalogue, and a Goal
producing none never advances it at all. So a model that answers each turn
with a paragraph of status and calls no tools continued until the budget ran
out, with nothing on the record that could ever end the loop.

A Goal now pauses after three consecutive autonomous turns that recorded no
evidence-bearing tool result and proposed no terminal state. `get_goal` and
`update_goal` results do not count -- they carry `provenance: 'goal_runtime'`,
and a turn that only re-reads its own state is the clearest form of the
idling in question. Only the runtime's own continuations are measured: a turn
carrying the user's text is the user steering and restarts the streak, and so
does a user turn reserved while the third quiet turn was still running. The
wind-down hand-off is exempt from both halves.

The streak lives on the record as `noProgressTurns`, exactly like
`checkpointStalls`, so a restart cannot launder it, and edit, replace, and
every resume clear it -- resuming is the user asking for another run at the
objective, not for its last turn. The count comes from a new optional
`takeGoalTurnToolResults` on the renamed `GoalTurnLedger`, fed by the recorder
where tool results are already stamped with the Goal permit. Optional is
load-bearing: a ledger that cannot answer switches the bound off rather than
reporting every turn as idle, and the bound reads the count measured on this
turn rather than the one on the record for the same reason.

The stop is a pause, not `blocked` or `usage_limited`: nothing was spent and
there is no evidence to cite, the Goal simply stopped producing. Following the
pause-reasons design, no new `GoalStateCause` is introduced and no host
changes -- every surface already renders a paused Goal's `lastReason`.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 7f045cc and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 7f045cc 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is filled in, and the Chinese translation is a real paragraph-for-paragraph rendering rather than a summary.

Problem: observed, not theoretical, and the two load-bearing claims check out against the tree. GOAL_DEFAULT_TOKEN_BUDGET really is 30_000_000 (goal-protocol.ts:43), so the budget is not a practical bound on a prose-only loop; and goal-runtime.test.ts:2238 really does assert "keeps the stall streak when a turn records no evidence at all", which is exactly the gap being described — the checkpoint bound advances on overflowing evidence, so a Goal producing none can never reach it. Tracked under the open roadmap issue #4228.

Direction: aligned, and better corroborated than the description claims. Beyond the Codex and Claude Code comparisons in the design doc, claude-code's own CHANGELOG carries /goal: "Changed idle sessions to start at most three check-ins on long-running background work per goal; your next message allows three more." That independently lands on the same threshold and the same reset rule this PR uses — three, and the user's next message starts the count over. Nothing here touches auth, sandbox, model selection, telemetry, release, or a public contract.

Size: core paths are touched (packages/core/src/goals/*, packages/core/src/services/chatRecordingService.ts, packages/core/src/config/config.ts). Breakdown: 220 production lines (goal-runtime 123, goal-reducer 32, chatRecordingService 31, goal-protocol 25, config 9), 483 test lines, 153 docs lines. feat type, so no size gate applies, and it is under both the 500-line awareness threshold and the 1000-line advisory. Five production files inside one subsystem is a coherent change, not a broad sweep.

I could name every downstream consumer, which is the bar for a core change here. The GoalTurnTokenLedgerGoalTurnLedger / tokenLedgerledger rename is the only part with reach outside the subsystem, and on the base tree those two identifiers appear in exactly three files — goal-runtime.ts, config.ts, and goal-runtime.test.ts — all three updated by this PR, with all ten test call sites accounted for. noProgressTurns is a new optional record field parsed and validated in parseGoalRecord; GOAL_PAUSE_REASON_NO_PROGRESS reaches users through the lastReason path every surface already renders.

Approach: the scope feels right, and I could not find a materially simpler path. My own first instinct was to count tool calls from the runtime's turn state instead of plumbing a new accessor through the recorder — but the runtime never sees tool results, the recorder already stamps them with the Goal permit, and it already keeps a per-turn spend entry with identical consume-once semantics. Extending that ledger is the smaller change, not the larger one. The reuse discipline is good throughout: noProgressPausedSnapshot mirrors the existing usageLimitedSnapshot builder field for field, the threshold mirrors GOAL_CHECKPOINT_STALL_LIMIT, and staying on the existing pause cause instead of adding a GoalStateCause is what keeps the state parsers, persistence format, legacy projection, ACP replay, and shouldDisplayGoalStateCause untouched — I confirmed 'pause' already returns true there, so the claim that no host changes is accurate.

Two small things worth a thought, neither blocking. The rename is technically a drive-by, but it is genuinely motivated (the interface now carries what a turn spent and what it produced) and contained to 12 lines, so splitting it out would cost more than it saves. And the committed design doc lists .qwen/e2e-tests/2026-09-07-goal-no-progress-pause.md among its artefacts — that path is git-ignored, so the reference is dead for anyone else reading the doc later.

The fail-open choices are the part I looked hardest at, and they are the right way round: an absent or throwing ledger answers undefined and switches the bound off for the session, and the bound tests the count measured on the finishing turn rather than the count sitting on the record. Getting that backwards would stop Goals that were working, which is the expensive failure.

Risk: no elevated risk signals — nothing in the diff matches the revert-correlated paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小节都填写了,中文部分是逐段对应的真实翻译,不是摘要。

问题: 是已观测到的问题,不是理论性加固,而且两个关键论断在代码树里都核对得上。GOAL_DEFAULT_TOKEN_BUDGET 确实是 30_000_000goal-protocol.ts:43),所以对只输出文字的循环来说预算并不是一道实际有效的闸;goal-runtime.test.ts:2238 也确实断言了 "keeps the stall streak when a turn records no evidence at all",而这正是所描述的缺口——checkpoint 连停计数靠证据溢出推进,一个完全不产出证据的 Goal 永远推不动它。该工作挂在仍然 open 的 roadmap issue #4228 下。

方向: 对齐,而且佐证比 PR 描述里写的更强。除了设计文档提到的 Codex 与 Claude Code 对照,claude-code 自己的 CHANGELOG 里就有 /goal"Changed idle sessions to start at most three check-ins on long-running background work per goal; your next message allows three more." 这独立地落到了与本 PR 相同的阈值相同的清零规则——3 次,且用户的下一条消息让计数重新开始。本 PR 不涉及 auth、sandbox、模型选择、telemetry、发布或对外契约。

规模: 触及核心路径(packages/core/src/goals/*packages/core/src/services/chatRecordingService.tspackages/core/src/config/config.ts)。拆分为:生产代码 220 行(goal-runtime 123、goal-reducer 32、chatRecordingService 31、goal-protocol 25、config 9)、测试 483 行文档 153 行。类型为 feat,不适用规模硬闸,且低于 500 行的维护者关注阈值与 1000 行的大 PR 建议阈值。5 个生产文件同属一个子系统,是一个内聚的改动,不是大面积横扫。

我能点名每一个下游消费者,这是核心改动的门槛。GoalTurnTokenLedgerGoalTurnLedger / tokenLedgerledger 的重命名是唯一影响到子系统之外的部分,而在基线代码树上这两个标识符只出现在三个文件里——goal-runtime.tsconfig.tsgoal-runtime.test.ts——本 PR 三个都改了,测试里的 10 处调用点也全部覆盖。noProgressTurns 是新增的可选记录字段,由 parseGoalRecord 解析与校验;GOAL_PAUSE_REASON_NO_PROGRESS 通过各界面本来就在渲染的 lastReason 通路到达用户。

方案: 范围合理,我没有找到明显更简单的路径。我自己的第一反应是从 runtime 的轮次状态里数工具调用,而不是给记录器新加一个访问器——但 runtime 根本看不到工具结果,记录器本来就带着 Goal permit 打标,而且已经维护着一个语义完全相同的"按轮次记账、消费一次"的开销条目。扩展这个 ledger 是更小的改动,不是更大的。整体的复用做得很好:noProgressPausedSnapshot 与既有的 usageLimitedSnapshot 逐字段对应,阈值对齐 GOAL_CHECKPOINT_STALL_LIMIT,而沿用已有的 pause cause、不新增 GoalStateCause,正是让状态解析器、持久化格式、legacy projection、ACP replay 和 shouldDisplayGoalStateCause 全都不用动的原因——我确认了 'pause' 在那里本来就返回 true,所以"不改任何 host"的说法是准确的。

两点小的想法,都不构成阻塞。重命名严格说属于顺手改动,但它确有理由(这个接口现在同时承载一轮花了多少和产出了什么),而且只有 12 行,拆出去的成本高于收益。另外,提交进仓库的设计文档在产物清单里列了 .qwen/e2e-tests/2026-09-07-goal-no-progress-pause.md——该路径是 git-ignored 的,所以对之后读这份文档的人来说这个引用是死链。

我看得最仔细的是几处 fail-open 的取舍,方向是对的:ledger 缺失或抛错时返回 undefined,让这道闸在整个会话里关闭;判断用的是本轮实测到的计数,而不是记录上读到的计数。把这两件事搞反就会停掉正在干活的 Goal,那才是代价高的失败。

风险: 无升级风险信号 —— diff 里没有匹配到与 revert 相关的路径。

进入代码审查 🔍

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

Reviewed at 7f045cc547b43a6ab6c4052e6627fb31ab7add66 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

No correctness blockers. The mechanism is wired end to end, and the parts I expected to be loose are not.

What I checked rather than assumed:

  • The rename is fully consumed. On the base tree GoalTurnTokenLedger and tokenLedger appear in exactly three files — goal-runtime.ts, config.ts, goal-runtime.test.ts — and all ten test call sites are updated. No stale import, no second wiring site, nothing outside the package. The description's claim is accurate.
  • The goal_runtime exclusion is a live switch, not a dead one. get_goal and update_goal results really do arrive stamped provenance: 'goal_runtime', because all four hosts go through the shared goalToolResultProvenance helper (coreToolScheduler.ts:6459, nonInteractiveCli.ts:2292, use-llm-stream.ts:4079 and :5365) or its ambient variant (Session.ts:10961 for ACP). That helper returns undefined outside a Goal turn, so ordinary recording is untouched. Had any host skipped it, that host's Goal turns would all read as idle and the bound would stop working Goals — worth stating explicitly because it is the one way this design fails unsafe, and it does not.
  • The delivery mark means what the design says it means. markTurnDelivered is called only under submitType === SendMessageType.Goal && goalBinding (use-llm-stream.ts:3958), so a user-typed message genuinely arrives undelivered and genuinely restarts the streak. Reusing that signal instead of inventing a "was this autonomous" flag is the right call.
  • No host change is needed, as claimed. shouldDisplayGoalStateCause('pause') already returns true, pause is already a GoalStateCause, and /goal pause already broadcasts it. Adding a reason constant rather than a cause keeps the state parsers, persistence format, legacy projection and ACP replay out of it.
  • The hand-built paused snapshot matches the house pattern. It is field-for-field the shape of the existing usageLimitedSnapshot and of the reducer's own pause branch — same elapsedActiveTime, same randomUUID() journal write, activity: 'idle', no revision bump. Because currentProposal being set forces the streak to 0, noProgressLimitReached implies no active proposal, which implies verifying === false, so the activity ternary and the journalled snapshot cannot disagree.

One suggestion — the skipped-checkpoint guard is not pinned by any test.

createCheckpointAttempt (goal-runtime.ts:332) returns an attempt whenever evidenceSource and checkpointVerifier are configured; it never looks at whether the turn recorded evidence. config.ts wires both, so in production the third quiet turn would mint a checkpoint without the new !noProgressLimitReached term. But noProgressHarness configures only journal and ledger, so createCheckpointAttempt returns undefined in all eleven new tests and that term never decides anything — delete it and the suite still passes.

The guard is correct and worth keeping for more than the wasted verifier call: with an attempt present, verifying flips true, so activity would be set to 'verifying' over the paused snapshot, and runCheckpoint would run and broadcast 'checkpoint' after the 'pause' broadcast. A harness variant passing both options, asserting no checkpoint record follows the third quiet turn and that activity stays idle, would pin it. Non-blocking — the untested path is not itself the defect.

Minor, same theme: takeTurnToolResults answers undefined when the ledger throws but 0 when it returns a non-finite number. 0 means "idle", which is the fail-unsafe reading, while undefined means "unmeasured". ChatRecordingService only ever returns an integer so this is unreachable today, but the asymmetry cuts against the design's own stated rule that "nothing measured" is not "nothing happened" — mapping a malformed answer to undefined too would make the reader consistent with its comment.

sequenceDiagram
    participant P1 as Host
    participant P2 as GoalRuntime finishTurn
    participant P3 as ChatRecordingService
    participant P4 as goal-reducer
    participant P5 as Journal
    participant P6 as Subscribers

    P3->>P3: recordToolResult stamps the permit, skips goal_runtime
    P1->>P2: finishTurn with the turn permit
    P2->>P3: takeGoalTurnToolResults for the turn
    P3-->>P2: a count, or nothing when it cannot answer
    P2->>P2: unmeasured or wind-down leaves the streak alone
    P2->>P2: undelivered, any tool result, or a proposal resets it to zero
    P2->>P2: otherwise the persisted streak advances by one
    P2->>P4: reduceGoalTurnFinished carrying the streak
    P4-->>P2: the next Goal record
    P2->>P5: journal turn_finished
    alt streak reached three and no user turn is waiting
        P2->>P5: journal pause with the shared reason
        P2->>P6: broadcast pause, no checkpoint and no continuation
    else
        P2->>P6: broadcast turn_finished
        P2->>P2: queueContinuation
    end
Loading

Testing

This is an unattended CI run, so the evidence below is the PR's own CI, read through the API — no PR code was built, run, or checked out here. Nothing was driven locally and there is no tmux capture to paste; real-scenario testing is N/A on this path.

No check on the reviewed commit has failed. The two that matter most for this diff — the unit suite and lint/typecheck on Linux — were still running when this comment was posted, so they are listed as pending rather than guessed at; the finalize job rewrites the table in place once they settle. Test (macos-latest), Test (windows-latest), Integration Tests (CLI, No Sandbox), tmux-testing and verify are skipped by the workflow for this PR, not failed.

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

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

The author reports 390 passing tests across goal-runtime, goal-reducer and chatRecordingService — that is the author's claim from their own machine, not something re-run here. Reading the eleven new runtime cases instead of trusting the count: they assert the things that would actually break, including the journalled cause sequence create, turn_finished ×3, pause with no fourth turn started, both fail-open ledger shapes, a restored streak being spent by the next measured quiet turn, a lost settle write still showing the stop, and a waiting user turn outranking the bound. Those are real assertions against the real runtime and reducer, not mocks of the thing under test.

Not verified: that a live model answering only in prose actually gets paused after three turns, and that /goal resume hands back the whole allowance — the author marks both as not run on this branch.

Sandboxed verification would settle this: @qwen-code /verify — the new tests drive a fake host and a fake ledger, so nothing exercises the real wiring, config.ts handing ChatRecordingService to the runtime as ledger and the four host call sites stamping results through goalToolResultProvenance. I traced that wiring statically above and it holds, but that the count reaches finishTurn before it is read, on a live model that really calls tools, is not observable from the diff — and the checkpoint guard would be pinned by the same run. @qwen-code /tmux would settle the user-facing half: the paused card rendering the new reason, and /goal resume restoring the full allowance rather than the last turn.

中文说明

代码审查

没有正确性阻塞项。这套机制是端到端接通了的,而我原以为会松的地方并不松。

我实际核对过、而不是想当然的部分:

  • 重命名被完整消费。 在基线代码树上,GoalTurnTokenLedgertokenLedger 只出现在三个文件里——goal-runtime.tsconfig.tsgoal-runtime.test.ts——测试里的 10 处调用点全部更新。没有遗留 import,没有第二个接线点,包外没有任何引用。PR 描述的说法是准确的。
  • goal_runtime 排除是一个真正生效的开关,不是死开关。 get_goalupdate_goal 的结果确实带着 provenance: 'goal_runtime' 进来,因为四个 host 全部走共享的 goalToolResultProvenance 助手(coreToolScheduler.ts:6459nonInteractiveCli.ts:2292use-llm-stream.ts:4079:5365),ACP 则走它的 ambient 变体(Session.ts:10961)。该助手在 Goal 轮之外返回 undefined,所以普通记录完全不受影响。假如有任何一个 host 漏掉了它,那个 host 上的 Goal 轮就会被全部读成空转,这道闸就会停掉正在干活的 Goal——之所以要明说,是因为这是本设计唯一会 fail-unsafe 的方式,而它没有发生。
  • 送达标记的含义与设计文档所述一致。 markTurnDelivered 只在 submitType === SendMessageType.Goal && goalBinding 下被调用(use-llm-stream.ts:3958),所以用户手打的消息确实是"未送达"、也确实会让计数重新开始。复用这个信号,而不是新造一个"这轮是否自主"的标志,是对的选择。
  • "不改任何 host"的说法成立。 shouldDisplayGoalStateCause('pause') 本来就返回 truepause 本来就是 GoalStateCause 之一,/goal pause 本来就在广播它。只加一个原因常量而不加 cause,正是让状态解析器、持久化格式、legacy projection 和 ACP replay 都不用动的原因。
  • 手工构造的 paused 快照符合本文件家族的既有写法。 它与既有的 usageLimitedSnapshot、以及 reducer 自己的 pause 分支逐字段对应——同样的 elapsedActiveTime、同样用 randomUUID() 落盘、activity: 'idle'、不抬 revision。由于 currentProposal 存在会把计数压成 0noProgressLimitReached 为真就蕴含没有 active proposal,也就蕴含 verifying === false,所以那个 activity 三元式与落盘的快照不可能互相矛盾。

一条建议——跳过 checkpoint 的那道保护没有任何测试钉住。

createCheckpointAttemptgoal-runtime.ts:332)只要配置了 evidenceSourcecheckpointVerifier 就会返回一个 attempt,它从不看这一轮有没有记录证据。config.ts 两者都接了,所以在生产里,如果没有新加的 !noProgressLimitReached 这一项,第三个空转轮确实会发出一次 checkpoint。但 noProgressHarness 只配置了 journalledger,于是 11 个新用例里 createCheckpointAttempt 一律返回 undefined,这一项从来没有决定过任何结果——把它删掉,测试照样全绿。

这道保护是对的,而且它的价值不止于省掉一次 verifier 调用:一旦有 attempt,verifying 就变成 true,于是 paused 快照上的 activity 会被写成 'verifying'runCheckpoint 会在 'pause' 广播之后执行并广播 'checkpoint'。加一个同时传入这两个选项的 harness 变体,断言第三个空转轮之后没有 checkpoint 记录、且 activity 保持 idle,就能把它钉住。不构成阻塞——未测到的这条路径本身不是缺陷。

一个小点,同一主题:takeTurnToolResults 在 ledger 抛错时返回 undefined,但在 ledger 返回非有限数值时返回 00 的含义是"空转",那是 fail-unsafe 的读法,而 undefined 的含义是"没测到"。ChatRecordingService 只会返回整数,所以今天这条路径不可达;但这个不对称与设计文档自己立的规则("没测到"不等于"什么都没发生")是相冲的——把一个畸形的返回值也映射成 undefined,会让这个读取函数与它自己的注释一致。

(时序图见上,中文不重复:记录器在记录工具结果时按 permit 计数并排除 goal_runtime;finishTurn 取走本轮计数;测不到或交接轮则不动计数,未送达/有工具结果/有提案则清零,否则加一;交给 reducer 得到下一条记录并落盘 turn_finished;若计数达到 3 且没有用户轮在等,就落盘 pause、广播 pause,既不发 checkpoint 也不发续跑;否则照旧广播 turn_finished 并排队续跑。)

测试

这是一次无人值守的 CI 运行,所以下面的证据是通过 API 读到的、本 PR 自己的 CI——这里没有构建、运行或 checkout 任何 PR 代码。本地没有驱动过任何东西,也没有 tmux 输出可以贴;这条路径上真实场景测试为 N/A。

被审查的 commit 上没有任何 check 失败。对这个 diff 最关键的两项——Linux 上的单测与 lint/typecheck——在本评论发出时仍在运行,所以如实列为 pending,不做猜测;它们结束后 finalize 任务会就地改写这张表。Test (macos-latest)Test (windows-latest)Integration Tests (CLI, No Sandbox)tmux-testingverify 是工作流对本 PR 跳过,不是失败。

作者报告 goal-runtimegoal-reducerchatRecordingService 共 390 个测试通过——这是作者在自己机器上的说法,不是这里重跑的结果。与其相信这个数字,我读了 11 个新的 runtime 用例:它们断言的是真正会坏掉的东西,包括落盘的 cause 序列 createturn_finished ×3、pause 且没有第四轮启动;两种 fail-open 的 ledger 形态;被恢复出来的计数只会被下一个实测到的空转轮花掉;落盘失败时仍然显示停机;以及等待中的用户轮优先于这道闸。这些是针对真实 runtime 与 reducer 的真实断言,不是对被测对象本身打 mock。

未验证:真实模型只输出文字时是否确实在三轮后 pause,以及 /goal resume 是否还回整份额度——作者自己标注这两项未在本分支上跑过。

沙箱验证可以把这件事定下来:@qwen-code /verify —— 新测试驱动的是假 host 与假 ledger,所以没有任何用例走到真实接线,也就是 config.tsChatRecordingService 作为 ledger 交给 runtime、以及四个 host 调用点通过 goalToolResultProvenance 打标。上面我已经静态追过这条接线并且它是通的,但"计数在被读取之前确实到达了 finishTurn"这件事,在一个真的会调用工具的真实模型上,是 diff 里看不出来的——同一次运行也会把上面那道 checkpoint 保护钉住。@qwen-code /tmux 则能定下用户可见的那一半:paused 卡片渲染出新的原因,以及 /goal resume 还回整份额度而不是最后一轮。

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

Reviewed at 7f045cc547b43a6ab6c4052e6627fb31ab7add66 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid across every stage; the only things I'd change are non-blocking, and I'd merge this once CI lands.

Stepping back: this is what a well-scoped core change looks like. I wrote down my own approach before reading the diff — a per-turn counter fed by the recorder, persisted on the record, paused rather than blocked, failing open when the count is unavailable — and the PR arrived at the same design, then went further on the two details I would have got wrong. It tests the count measured on the finishing turn rather than the count sitting on the record, so a streak restored from disk cannot stop a Goal whose ledger cannot see the turn that would have relieved it. And it distinguishes "the ledger cannot answer" from "the turn produced nothing", which is the whole difference between a bound that protects the user and one that eats working Goals.

The question I cared most about was whether anything could make this fail unsafe — a Goal stopped while it was genuinely working. There is exactly one way: a host that records tool results without threading the Goal permit, whose turns would then all read as idle. I checked all four. They all go through the shared provenance helper, and it excludes get_goal and update_goal uniformly, which also means the exclusion the design leans on is a live switch rather than a decorative one. Everything else — an absent ledger, a throwing ledger, an undelivered turn, a wind-down hand-off, a user turn waiting in the queue — falls toward keeping the Goal running. That is the right bias, and it is consistent rather than accidental.

Choosing pause over blocked or usage_limited is the correct read of the existing semantics, and it is also what makes this cheap: no new GoalStateCause, so no host, parser, persistence-format or ACP-replay change, and /goal resume is already the remedy a paused card invites. The threshold matching the checkpoint stall bound is a nice touch — one number to remember across the file family.

What keeps this at 4 rather than 5 is small and I've named it in the review above: the guard that skips the checkpoint on the stopping turn is never actually exercised, because the new harness configures neither evidenceSource nor checkpointVerifier and so createCheckpointAttempt returns undefined regardless. The guard is correct and matters in production — without it a paused Goal would still get a verifier call and a checkpoint broadcast after the pause. It just isn't pinned, and deleting it would leave all 390 tests green. Worth a harness variant; not worth holding the PR for. The takeTurnToolResults asymmetry between a throw and a non-finite return, and the design doc's link to a git-ignored .qwen/e2e-tests/ path, are the same order of nit.

On the behavioural claim: I could not watch a live model get paused, and neither could the author — the interactive walkthrough is explicitly not run on this branch. What I have instead is eleven assertions against the real runtime and reducer, plus a static trace of the wiring from every host through the recorder to finishTurn. That is enough for me to approve, and I've named @qwen-code /verify and @qwen-code /tmux above for whoever wants the end-to-end half settled before merge.

If I picked this up in six months I would thank the author: the design doc explains every exemption and why the failure direction was chosen, the constants and reason strings sit with their siblings, and the code reads like the file it lives in.

The unit suite and lint were still running when I wrote this, so I'm not approving in this run — approval deferred until CI lands green on 7f045cc547b43a6ab6c4052e6627fb31ab7add66, which the finalize job will post commit-pinned, and withhold if anything lands red or the head moves.

中文说明

Confidence: 4/5 —— 每个阶段都站得住;我想改的地方都不构成阻塞,CI 一绿我就会合。

退一步看:这是一个范围收得很好的核心改动该有的样子。在读 diff 之前我先写下了自己的方案——由记录器喂给它的按轮计数、持久化在记录上、pause 而不是 blocked、拿不到计数时 fail-open——而这个 PR 落到了同一个设计上,并且在我会做错的两个细节上走得更远。它判断的是本轮实测到的计数,而不是记录上读到的计数,所以从磁盘恢复出来的连停计数,不会停掉一个 ledger 根本看不到"本可以缓解它的那一轮"的 Goal。它也区分了"ledger 答不上来"和"这一轮什么都没产出",而这两者的区别,正是一道保护用户的闸与一道吃掉正在干活的 Goal 的闸之间的全部差别。

我最在意的问题是:有没有什么会让它 fail-unsafe——一个 Goal 在真正干活的时候被停掉。恰好只有一种途径:某个 host 在记录工具结果时没有把 Goal permit 带上,那么它的每一轮都会被读成空转。我把四个 host 全查了。它们都走共享的 provenance 助手,而且该助手一致地排除了 get_goalupdate_goal——这同时也意味着设计所依赖的那个排除是真正生效的开关,不是装饰。其余所有情况——ledger 缺失、ledger 抛错、未送达的轮次、wind-down 交接轮、在队列里等待的用户轮——都倒向让 Goal 继续跑。这个偏向是对的,而且是贯穿一致的,不是碰巧。

pause 而不是 blockedusage_limited,是对既有语义的正确解读,也正是它让这次改动便宜:不新增 GoalStateCause,于是 host、解析器、持久化格式、ACP replay 全都不用动,而 /goal resume 本来就是一张 paused 卡片所邀请的补救方式。阈值与 checkpoint 连停上限保持一致也是个好细节——整个文件家族只需要记住一个数字。

让它停在 4 而不是 5 的东西很小,我在上面的审查里已经点名:停机那一轮跳过 checkpoint 的那道保护实际上从来没有被执行过,因为新的 harness 既没有配置 evidenceSource 也没有配置 checkpointVerifier,于是 createCheckpointAttempt 无论如何都返回 undefined。这道保护是对的,而且在生产里是有意义的——没有它,一个已经 paused 的 Goal 仍然会挨一次 verifier 调用,并且在 pause 之后再广播一次 checkpoint。它只是没有被钉住,把它删掉 390 个测试照样全绿。值得加一个 harness 变体;但不值得为此扣住这个 PR。takeTurnToolResults 在"抛错"与"返回非有限数值"之间的不对称,以及设计文档里链向 git-ignored 的 .qwen/e2e-tests/ 路径,都是同一量级的小点。

关于行为层面的论断:我没能看着一个真实模型被 pause,作者也没能——交互式走查明确没有在本分支上跑过。我手上代替它的是十一条针对真实 runtime 与 reducer 的断言,外加一条从每个 host 经记录器到 finishTurn 的静态接线追踪。这对我足够批准了,而上面我也点名了 @qwen-code /verify@qwen-code /tmux,谁想在合并前把端到端那一半定下来都可以用。

如果六个月后由我来维护这份代码,我会感谢作者:设计文档解释了每一处豁免、以及为什么选这个失败方向;常量与原因字符串都放在它们的同类旁边;代码读起来就像它所在的那个文件。

我写这段的时候单测与 lint 还在跑,所以本次运行不发批准——批准推迟到 CI 在 7f045cc547b43a6ab6c4052e6627fb31ab7add66 上全绿之后,由 finalize 任务按 commit 绑定发出;若有任一 check 变红或 head 移动,它会扣住不发。

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

Reviewed at 7f045cc547b43a6ab6c4052e6627fb31ab7add66 · 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.

LGTM, looks ready to ship — CI landed green after the review. ✅

A ledger that returns something other than a finite count was read as
zero, which spends one of the three turns on a measurement that never
happened. It now says the same thing as a ledger that throws: the bound
is off for that turn.

Also pin that the turn which spends the streak takes no checkpoint --
the guard was in the code with nothing holding it there -- and point the
design doc's verification list at the pull request's evidence rather
than at an ignored path.
@qqqys

qqqys commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Took both suggestions from the review, in 16d65d1:

  • takeTurnToolResults read a non-finite answer as 0, which is "the turn produced nothing" and spends one of the three. A ledger that cannot answer in a count now says what one that throws says: unmeasured, bound off for that turn. The design doc's own words were "not measured is not the same as did not happen", and the code did not hold to it.
  • The checkpoint skip on the stopping turn had nothing pinning it. The no-progress harness now takes an evidence source and a checkpoint verifier, and a case asserts the journal reads create, turn_finished, checkpoint, turn_finished, checkpoint, turn_finished, pause — checkpoints on the first two quiet turns, none on the one that stops the Goal. It fails if the guard is removed; I checked.
  • The design doc's verification list pointed at an ignored path. It now points at this PR's Evidence, and covers the two new cases.

On the last run's red checks: Lint & Static was killed at the 90-minute runner cap with every completed step green (29 minutes installing, 14 in ESLint, 24 in Prettier on hk3-8), and web-shell E2E Smoke fails the same way on unrelated open PRs (#11265, #11248). This push reruns both.

中文说明

评审里两条建议都采纳了,在 16d65d1

  • takeTurnToolResults 把非有限的返回值读成 0,也就是"这一轮什么都没产出",于是三轮里就被花掉一轮。现在一个答不出计数的 ledger 和一个抛错的 ledger 说同一件事:没测到,这一轮不计入。设计文档自己写的是"没测到不等于没发生",代码没有做到。
  • 停机那一轮跳过 checkpoint 的守卫没有任何测试钉住。no-progress 的 harness 现在可以带上证据源和 checkpoint verifier,新用例断言 journal 读作 create, turn_finished, checkpoint, turn_finished, checkpoint, turn_finished, pause——前两个空转轮各有一次 checkpoint,真正停下 Goal 的那一轮没有。把守卫去掉这个用例就会失败,我验过。
  • 设计文档的验证清单指向了一个被 gitignore 的路径,现在改为指向本 PR 的 Evidence,并覆盖了新增的两个用例。

关于上一次运行里红的检查:Lint & Static 是在 90 分钟 runner 上限被杀的,已经跑完的步骤全绿(hk3-8 上装依赖 29 分钟、ESLint 14 分钟、Prettier 24 分钟);web-shell E2E Smoke 在无关的开放 PR 上(#11265#11248)以同样方式失败。这次推送会把两个都重跑。

wenshao
wenshao previously requested changes Sep 7, 2026

@wenshao wenshao 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.

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

  • R1-4 non-finite ledger answer mapped to idle instead of unmeasured — packages/core/src/goals/goal-runtime.ts:385 — already reported (comment 5564540945)
  • R1-8 checkpoint-skip guard not pinned by the tests the diff adds — packages/core/src/goals/goal-runtime.ts:1641 — already reported (comment 5564540945)

Test Plan (not a blocker): src/goals/goal-reducer.test.tsno such file or directory; src/goals/goal-runtime.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; Tests 390 passed — this review observed 1959, 298, 1818, 509, 6362 passed.

中文说明

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

Test Plan(非阻断):src/goals/goal-reducer.test.tsno such file or directory; src/goals/goal-runtime.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; Tests 390 passed — this review observed 1959, 298, 1818, 509, 6362 passed

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

Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread docs/users/features/goals.md Outdated
Comment thread packages/core/src/goals/goal-protocol.ts Outdated
Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts Outdated
Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts
… budget, and a stalled checkpoint

Three of the bound's neighbours outrank it, and the pause write opened a
window where a fourth was lost.

A user turn reserved while the pause record was being appended was
discarded: `beginTurn` is synchronous and does not queue, the guard read the
reservation once before the await, and the later `queuedTurnKey` assignment
erased it. The guard now re-reads the reservation after the write and
declines the pause when one appeared, the way `stopForSpentBudget`
re-validates after its own write. The journal may then hold a `pause` record
the runtime never adopted; that is the shape the budget stop already leaves,
and the conservative side to land on -- a restart recovers a paused Goal with
its reason, and resume is the whole remedy.

The bound also stands down on the turn where a better-fitting limit
coincides with it. A spent token budget reaches the continuation gate, which
grants the wind-down hand-off and stops as `usage_limited`/`token_budget`
instead of an idle pause with no `limitKind`. A Goal carrying a checkpoint
stall streak lets its checkpoint run, so the stall breaker stops it with the
evidence-catalog reason instead of a pause whose remedy would resume it into
the same overflowing window.

The paused snapshot is now built by the same `settledSnapshot` helper as the
usage-limited one, so the settled shape lives in one place.

Tests: a reservation landing inside the pause write is served; a restored
streak at the limit is not spent by a turn the ledger cannot measure; the
stall breaker wins three quiet overflowing turns; the budget wins the
crossing turn. Each goes red under the corresponding mutation.
The reason is runtime-emitted and headless-reachable, so it must not point at
a slash command a process that has already exited cannot run; and it said
"no tool results" about a Goal whose transcript shows three turns of
`get_goal`/`update_goal` results, which are recorded but do not count. It now
says "nothing to judge", offers resume, and offers edit only together with
resume -- editing a paused Goal leaves it paused. The user doc says the same
and names the bookkeeping reads that do not count.

The constant joins the headless-register test, which now also rejects any
`/goal ` mention, and the shared-constant validation loop.
The replay projected every `pause` record as the user typing `/goal pause`.
The no-progress bound is the first `pause` the runtime writes with nobody at
the keyboard, so a replayed session attributed the autonomous stop to the
person who was away. A `pause` now replays as a user message only when its
reason says the user typed it (`GOAL_PAUSE_REASON_COMMAND`, exported on the
goal wire) or when the record predates pause reasons; every other pause
replays as the paused card alone, which carries the reason. `resume` and
`clear` are unchanged.

@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.

Not reviewed: test-efficacy probe — the probe runner executed against an unbuilt tree outside the PR worktree, so no whole-file revert, deletion mutant or per-hunk probe ran (harnessValidated: null): whether the new no-progress tests fail when the behaviour is removed is unmeasured, not clean.

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

Test Plan (not a blocker): src/goals/goal-reducer.test.tsno such file or directory; src/goals/goal-runtime.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; Tests 390 passed — this review observed 23604, 1959, 28862, 298, 1818, 504, 6362 passed.

中文说明

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

未审查(原文为英文):test-efficacy probe — the probe runner executed against an unbuilt tree outside the PR worktree, so no whole-file revert, deletion mutant or per-hunk probe ran (harnessValidated: null): whether the new no-progress tests fail when the behaviour is removed is unmeasured, not clean.

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

Test Plan(非阻断):src/goals/goal-reducer.test.tsno such file or directory; src/goals/goal-runtime.test.tsno such file or directory; src/services/chatRecordingService.test.tsno such file or directory; Tests 390 passed — this review observed 23604, 1959, 28862, 298, 1818, 504, 6362 passed

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

Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts Outdated
Comment thread packages/core/src/goals/goal-runtime.ts Outdated
Comment thread packages/core/src/goals/goal-runtime.ts
Comment thread packages/core/src/goals/goal-runtime.ts Outdated
Comment thread packages/core/src/goals/goal-protocol.ts Outdated
Comment thread packages/core/src/goals/goal-protocol.ts
Comment thread packages/core/src/config/config.ts
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to 1fc7424193229c67d79d5e6205b1922abbd90808 while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: 16d65d111242d74108997b5fd69cc8c6f6b3b605 (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 1fc7424193229c67d79d5e6205b1922abbd90808(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head 16d65d111242d74108997b5fd69cc8c6f6b3b605 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

@qqqys

qqqys commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Description corrected against the head commit — four things had gone stale as the fixes landed.

The "no host changes" claim was wrong. 4639aa0 widens one: the interactive path that renders a terminal card right after a turn ended by a turn-terminating tool listed complete, blocked and usage_limited only, so a Goal this bound paused at the end of an update_goal turn finished silently in the TUI. It now includes paused, mapped to the pause cause the card is keyed to. The other surfaces really do need no work — the runtime's broadcast reaches the TUI as the goal-state stream event the transcript already renders, and /goal, ACP and headless all render a paused Goal's reason already.

The test plan numbers were from the first commit. Now 165 runtime cases and 1,027 across the five files, with goal-protocol.test.ts and config.test.ts added to the command since both carry assertions this PR added. Seventeen cases under "no-progress bound", not eleven.

The Evidence was rerun rather than re-quoted. The lastReason in the old block was the current constant, but the run that produced those numbers predated the rewording, so it was not what that run printed. Rather than annotate it I rebuilt the head commit and ran the same objective again, and the new block is that run verbatim.

The rerun is more interesting than the original, so it replaces it rather than joining it:

turn | status  | noProgressTurns | tokensUsed
   1 | active  | 1               |  20,251
   2 | active  | 2               |  41,044
   3 | active  | -               |  86,634
   4 | active  | 1               | 110,495
   5 | active  | 2               | 134,677
   6 | active  | -               | 214,177
 7-11 | active | -               | 594,252
  12 | active  | 1               | 637,883
  13 | active  | 2               | 681,843
  14 | paused  | 3               | 726,310

The model called nothing but get_goal and update_goal, seven of each. Every update_goal turn restarted the streak, which is the rule working as written — a terminal proposal is something to judge even when the verifier rejects it. Turns 12 to 14 are the first three consecutive turns that proposed nothing, and the bound fires on the third. Worth saying plainly in the description, so it now says it: this is a floor under turns that produce nothing at all, not under a model that keeps proposing, and that second shape is still bounded only by the token budget.

And the description no longer points at .qwen/e2e-tests/, which is gitignored and cannot be opened from a review.

Build note for anyone reproducing: npm run build does not produce bundle/gemini.js any more; run npm run bundle and the entry is dist/cli.js.

中文说明

按 head 提交把描述里四处已经过期的内容改掉了。

「不改任何 host」这句是错的。 4639aa0 就改了一处:交互路径上「由终止回合的工具结束的那一轮」结束后渲染终态卡片的判断只列了 completeblockedusage_limited,所以被这道闸在 update_goal 轮末停掉的 Goal 在 TUI 里是无声的。现在把 paused 也纳入,并映射成卡片依据的 pause cause。其他界面确实不需要改——运行时的广播会以 goal-state 流事件到达 TUI,而那条路本来就会渲染;/goal、ACP、headless 也早就会渲染 paused Goal 的原因。

测试计划里的数字还是第一个提交时的。 现在是 165 个 runtime 用例、五个文件共 1,027 个;命令里补上了 goal-protocol.test.tsconfig.test.ts,这两个文件也有本 PR 新增的断言。"no-progress bound" 下是 17 个用例,不是 11 个。

Evidence 是重跑的,不是重新引用的。 旧那段里的 lastReason 是当前常量,但产出那些数字的那次运行早于改词,所以并不是那次运行打出来的东西。与其加注,我重新构建了 head 并用同一个目标又跑了一遍,新那段就是这次运行的原文。

重跑的结果比原来那次更有意思,所以是替换而不是并列(表格见英文部分)。模型除了 get_goalupdate_goal 各七次之外没调用任何工具。每一个 update_goal 轮都让计数重新开始——这正是规则本身:终态提案是"可判断的东西",哪怕 verifier 驳回它。第 12 到 14 轮是头三个连续什么都没提案的轮次,闸在第三轮触发。这一点值得在描述里挑明,现在也写进去了:它是"完全没有产出的轮次"的下限,不是"一直在提案的模型"的下限,后者目前仍然只被 token 预算兜底。

描述里也不再指向 .qwen/e2e-tests/,那个目录是 gitignored 的,评审打不开。

复现提示:npm run build 已经不再产出 bundle/gemini.js 了,要跑 npm run bundle,入口是 dist/cli.js

@qwen-code-dev-bot qwen-code-dev-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.

Approved at head 4639aa01e2.

Scope of what I signed. A Goal that spends three consecutive autonomous turns producing nothing to judge now pauses instead of burning turns forever, with the bound yielding to anything that outranks it. 17 threads, every one answered and resolved; the two blocking rows on the page are anchored four commits back at 16d65d1112.

The Critical both reviewers raised (their R1-1, same defect) is closed, and I read the fix rather than the reply. The reservation race was real: nextTurnKey was captured before the await on the pause journal write, beginTurn is synchronous and does not pass through enqueue, so a keystroke landing inside that window reserved a turn that the subsequent queuedTurnKey = verifying ? nextTurnKey : undefined then erased with a provably-stale local. At this head: goal-runtime.ts:1678 reads the key, :1684 guards the bound on it, and after the write :1709-1710 re-reads and abandons the pause if anything arrived, so the commit at :1745 and the promotion branch at :1748 both work off the fresh value. serves a user turn reserved while the pause was being written covers exactly the interleaving that was reported, with the reservation created during the append. I also checked the shape the reply conceded — a pause record that lost the race stays in the journal uncommitted — and agree it is the conservative side: a restart recovers a paused Goal with its reason, and /goal resume is the whole remedy. It is the same residue stopForSpentBudget already leaves, and both are written down at :1704-1708.

The newest commit's reordering needed a follow-up check, and it passes. :1658 now builds the checkpoint attempt unconditionally and :1733 drops it only when the pause actually fires, which is the right fix for "a Goal drowning in prose evidence hits the bound first and never checkpoints" — the steering path keeps its checkpoint. But that also means the turn_finished record at :1661-1676 can carry a checkpointPending for an attempt the live runtime just discarded. I chased that through recovery: goal-persistence.ts:53-60 scans from the end and takes the last goal_state payload, so the pause record written immediately after supersedes it and nothing dangles on restart; and when the pause write itself throws (the catch at :1692, pinned by shows the no-progress stop even when the settle write fails), the earlier record's pending checkpoint is precisely what you want restored. That is the failure mode this shape usually has, so it is the first thing I looked for.

Precedence and fail-open directions are the part I'd have asked for unprompted. Three named tests pin that the bound loses to a waiting reservation (:5948), to the checkpoint stall breaker on an overflowing window (:6091), and to a token budget spent on the same turn (:6146) — the last two close the "the new bound jumps the queue in front of the gates that already existed" complaint, including the one about the spent-budget gate living only inside queueContinuation(). Uncountable ledger answers stay unmeasured rather than counted as quiet: three separate tests for "cannot count", "throws", "answers with something that is not a count" leave the bound off, which is the correct bias for a breaker that pauses a user's Goal.

Also verified in code, not in replies. The ACP replay no longer attributes a runtime pause to the user — transcript-replay.ts emits /goal pause only for a typed command or a record predating reasons, with the card carrying lastReason either way (this was the sharpest of the findings: a fabricated user message in a resumed transcript). GOAL_PAUSE_REASON_NO_PROGRESS now reads "recorded nothing to judge and no proposal", which is what the implementation counts, replacing the earlier "no tool results" that the get_goal/update_goal exclusion contradicted. The duplicated snapshot builders are collapsed into settledSnapshot (goal-runtime.ts:430, :458-461). The CLI host maps the new status to the right broadcast (use-llm-stream.ts: cause: status === 'paused' ? 'pause' : status), and restarts the streak on a turn the user drove / exempts the wind-down hand-off / clears the streak when the user resumes pin the three ways the streak must not accumulate.

CI facts. At this head Test (ubuntu-latest) passed in 23m50s, Lint & Static in 16m13s, Integration Tests (no-AK) in 8m36s, and the Desktop Shell and meta lanes are green; review-pr was still running and nothing has reported red. I did not re-run the core suite locally — that lane is the one that executes the new ~600 lines of goal-runtime.test.ts, and it is green. One PENDING review row from qqqys sits on this head, so a human pass may still be landing; my vote is on the content. The page's CHANGES_REQUESTED is two stale rows anchored at 16d65d1112, and @qwen-code /triage is what clears them.

@yiliang114 yiliang114 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.

Independent verification at head 4639aa01e — the one Critical is closed, and I read the fix rather than the replies

No findings from me. This is a confirmation pass, because AGENTS.md treats an approval as a claim that has to be checked against the code as it stands, and both blocking reviews on the page are anchored four commits back at 16d65d111.

R1-1 (raised independently by @wenshao and by ci-bot, same defect) — closed

The reservation race was real as reported: nextTurnKey captured before the await on the pause journal write, beginTurn synchronous and not routed through enqueue, so a keystroke inside that window reserved a turn that the later queuedTurnKey = verifying ? nextTurnKey : undefined then erased with a provably stale local.

At this head, in goal-runtime.ts:

  • :1678 let nextTurnKey = queuedTurnKey;let, not const, which is the whole point.
  • :1684 if (noProgressLimitReached && !nextTurnKey) — the bound yields to a turn reserved before it.
  • :1686-1691 the await options.journal.recordGoalState(...) pause write — the race window.
  • :1692-1698 catch {} with the reasoning inline, so a lost settle write cannot strand an "active" Goal that nothing will continue.
  • :1709 nextTurnKey = queuedTurnKey;re-read after the await, mirroring stopForSpentBudget's post-write re-validation.
  • :1710 if (nextTurnKey) noProgressSnapshot = undefined; — the reservation wins and the pause is abandoned, not merely postponed.
  • :1745 and :1748 both consume the refreshed value, so neither the queue write nor the promotion branch can act on the stale capture.

The reordering in the newest commits needed a second check, and it holds

:1658-1660 builds nextCheckpoint gated only on !activeProposal, i.e. independently of the no-progress bound, and :1661-1676 journals turn_finished with checkpointPending when it exists. :1733 then does checkpointAttempt = noProgressSnapshot ? undefined : nextCheckpoint; — the attempt is dropped only when the pause actually fires. So when :1710 abandons the pause, noProgressSnapshot is undefined and the steering turn keeps its checkpoint, which is the behaviour R1-7 asked for.

That shape leaves a turn_finished record carrying a checkpointPending for an attempt the live runtime discarded. I chased the recovery path myself rather than accepting the reply: goal-persistence.ts:48-62, selectGoalRecoveryFromRecords, iterates for (let index = records.length - 1; index >= 0; index -= 1), skips non-goal_state records, and returns on the first parseable V2 payload. Last goal_state wins, so the pause record written immediately after supersedes the dangling checkpointPending and nothing dangles on restart. The same reading covers the conceded residue of a pause record the runtime never adopted: recovery yields a paused Goal with its reason, and resume is the whole remedy.

R1-17 / R1-3 (user-facing accuracy) — addressed

GOAL_PAUSE_REASON_NO_PROGRESS at goal-protocol.ts:420-421 now reads "Three Goal turns in a row recorded nothing to judge and no proposal", not "no tool results" — which matches the counting rule that deliberately excludes get_goal/update_goal via provenance: 'goal_runtime'. The reason is also host-neutral, so the headless case reads correctly.

One item I did not verify

R1-16 — whether the CLI's detached tool-continuation settle knows that finishTurn can now produce paused. I could not locate that file within budget, so I am certifying nothing about it. It is a Suggestion, it is resolved with an author reply, and the runtime side does carry the shared shape (goal-runtime.ts:406 status: 'paused' | 'usage_limited'), so this is a gap in my pass rather than a finding.

CI at this head

Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, OpenTUI no-flicker gate and TUI parity snapshots (ink vs opentui) are all success. One review-pr is still in_progress.

What is actually holding this PR

Not the code. reviewDecision is CHANGES_REQUESTED because of two reviews anchored at 16d65d111@wenshao's pullrequestreview-5131979023 and ci-bot's pullrequestreview-5132835914 — and neither has been dismissed by the commits since. The Critical both of them raised is the one I verified closed above.

Comment rather than approve: this was invoked as a review, and @wenshao's request-changes is a human reviewer's to withdraw, not mine to dismiss. On the evidence I read at this head I have no objection to approval; the ci-bot review is the stale one and a fresh review-pr round is already running.

@chiga0 chiga0 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.

No blocking findings. Approved.


Tier: Deep — persisted format change (noProgressTurns field), async state machine with a race condition path, hard to reverse.

Scope: goal-{protocol,reducer,runtime}.ts, chatRecordingService.ts, acp-bridge/transcript-replay.ts, use-llm-stream.ts, config.ts, all associated test files. NOT reviewed: docs/ (design doc and user guide) — content only, no code impact.


What I checked

Class 1 (contract asymmetry)

  • GoalTurnLedger.takeGoalTurnToolResults optional → ledger absence switches bound off rather than marking every turn idle. Conservative direction is correct: "nothing measured" ≠ "nothing happened."
  • ChatRecordingService.takeGoalTurnToolResults returns 0 for an unknown turnId; runtime treats 0 as "no results, increment streak" and undefined as "can't answer, disable bound." Mapping is correct.
  • Single-entry pattern (new turnId resets the count) mirrors existing goalTurnTokens. Sequential Goal turns make this safe in practice.

Class 2 (API/compatibility)

  • GoalTurnTokenLedger → GoalTurnLedger, tokenLedger → ledger: diff confirms only config.ts and goal-runtime.test.ts updated; author states nothing external referenced either name.
  • GOAL_PAUSE_REASON_COMMAND newly exported on goal-wire.ts — additive, callers gain a stable constant.
  • No new GoalStateCause value: keeps change out of state parsers, persistence format, legacy projection.

Class 3 (error handling)

  • Failed pause journal write: swallowed, in-memory snapshot shows stop anyway, continuation blocked. Covered by "shows the no-progress stop even when the settle write fails."
  • Race (beginTurn during journal write): post-fix code re-reads live queuedTurnKey after the await and abandons the pause when a reservation appeared, mirroring stopForSpentBudget's post-write re-validation. Covered by "serves a user turn reserved while the pause was being written."

Class 10 (stated intent vs code)

  • use-llm-stream.ts adds 'paused' to terminal-status check — matches PR description's "one host branch did need widening." Test parameterized to cover complete and paused.
  • transcript-replay.ts: returns undefined for all non-GOAL_PAUSE_REASON_COMMAND pauses — matches design doc. Broader than just the new reason; also corrects pre-existing misattribution of runtime-written pauses (SESSION_DISPOSED, HEADLESS_RUN_ENDED, etc.). Intentional.
  • noProgressTurns: zero spelled as no field, matching checkpointStalls. Parser rejects negative and fractional counts.

Cross-check

R1-1 (Critical, goal-runtime.ts:1684) — filed by both wenshao and qwen-code-ci-bot: user-turn reservation during pause journal write was dropped because nextTurnKey was captured before the await and not re-read after. Author fixed in commit 16d65d1; current head 4639aa01 contains the fix (let nextTurnKey re-read after the write + "serves a user turn reserved while the pause was being written" test). Confirmed addressed.

Remaining suggestions from both reviewers (R1-2 through R1-17: wording, minor structural notes, single-entry pattern documentation) — none are blockers.


Not covered

  • Mutation probe (no working tree): 17 runtime test cases look non-vacuous from static analysis — they check specific state transitions (status: 'paused', noProgressTurns count, lastReason text) that would fail with the bound code reverted. PR also includes an end-to-end live model run as execution evidence.
  • macOS / Windows: author acknowledges ⚠️; no platform-specific code in the diff.

Reviewed with AI assistance.

@qqqys
qqqys dismissed stale reviews from qwen-code-ci-bot and wenshao September 8, 2026 06:42

fixed

@qqqys
qqqys enabled auto-merge September 8, 2026 06:42

@yiliang114 yiliang114 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.

Approving at head 4639aa01e. Evidence is in pullrequestreview-5138243426; restating what the vote rests on.

The one Critical on this PR (R1-1, raised independently by @wenshao and ci-bot — the reservation race where a user turn reserved during the pause journal write was erased by a stale local) is closed, and I read the fix at head rather than the replies: goal-runtime.ts:1678 declares let nextTurnKey, :1684 guards the bound on it, :1709 re-reads it after the await, :1710 abandons the pause when a reservation arrived, and :1745/:1748 both consume the refreshed value.

The reordering in the newest commits also holds: :1658-1660 builds the checkpoint attempt independently of the bound and :1733 drops it only when the pause actually fires, so an abandoned pause leaves the steering turn with its checkpoint. The turn_finished record can still carry a checkpointPending for an attempt the live runtime discarded; I chased that through recovery myself — goal-persistence.ts:48-62 scans records backwards and returns the first parseable goal_state, so the pause record written immediately after supersedes it and nothing dangles on restart.

GOAL_PAUSE_REASON_NO_PROGRESS (goal-protocol.ts:420-421) now reads "recorded nothing to judge and no proposal", matching the counting rule that excludes get_goal/update_goal.

CI at this head: Test (ubuntu-latest), Lint & Static, Integration Tests (no-AK), web-shell E2E Smoke, OpenTUI no-flicker gate, TUI parity snapshots all success; one review-pr still running.

One gap in my pass, stated so it is on the record rather than implied as covered: I did not verify R1-16 (whether the CLI's detached tool-continuation settle handles paused). I could not locate that file within budget. It is a Suggestion, resolved with an author reply, and the runtime side carries the shared status: 'paused' | 'usage_limited' shape at :406 — so I am not certifying it, just not blocking on it.

@qqqys
qqqys added this pull request to the merge queue Sep 8, 2026
Merged via the queue into QwenLM:main with commit bbc8ea6 Sep 8, 2026
165 of 168 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants