Skip to content

feat(goal): carry budget figures and progress guidance in the continuation prompt - #11257

Merged
wenshao merged 13 commits into
QwenLM:mainfrom
qqqys:feat/goal-continuation-budget
Sep 8, 2026
Merged

wenshao merged 13 commits into
QwenLM:mainfrom
qqqys:feat/goal-continuation-budget

Conversation

@qqqys

@qqqys qqqys commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Every runtime-scheduled Goal turn now reports its token usage, limit, remainder, and completed-turn count when those figures are available. Non-wind-down turns carry four standing progress checks, except when supplied usage identifies turnCount === 0; that turn has no previous turn to judge, so it carries three.

The four lines ask it to treat the workspace and this turn's tool results as authoritative rather than what earlier turns reported, to work toward the end state the objective asks for rather than a narrower one it can reach sooner, to judge whether its previous turn actually changed anything before spending this one, and to check every explicit requirement against citable evidence before proposing that the Goal is done. The judgement line is held back on the Goal's very first turn, which create schedules before any turn has finished: there is no previous turn to judge there, and asking for the judgement invites the model to describe one.

Placement is deliberate. The figures sit after the standing objective guard and before the objective-updated notice. They stay outside the data block because that block holds explicitly untrusted task data, while the figures are trusted runtime context. The remainder is clamped at zero because the wind-down turn may begin after the window is overspent.

The progress lines are skipped on that wind-down hand-off, which is told not to start new work: a line asking for "a different concrete action now" would contradict it. The budget line is kept there, because a hand-off reports the numbers it stopped at.

usage remains optional on the public host contract. When it is absent, the Token budget: line is omitted and all four non-wind-down progress checks render because no turn count is available. The renderer and host queues share a GoalContinuationUsage projection from GoalRecord, and the host tests cover each queue copy.

Why it's needed

The model could not see how much of its spend window was left. A Goal stops when tokensUsed reaches tokenBudget, 30,000,000 by default, and gets one wind-down turn to hand off. Until that turn arrived there was no signal at all, so the model could not tell turn 3 of a long run from the turn before the budget stops it, and so could not choose between opening a broad investigation and finishing what it had in hand. get_goal can provide the same figures, but asking for them costs an extra tool call and does not make the budget visible at the start of every scheduled turn.

It was also never asked whether its last turn accomplished anything. The verifier only ever sees a terminal proposal, so a turn that proposes nothing is judged by nobody, and a turn spent restating status is exactly the turn that proposes nothing.

Codex's continuation template runs to 56 lines and covers both, with the same budget figures plus work-from-evidence, no-progress, fidelity and completion-audit sections. This is the equivalent for the parts that apply here. The blocked-audit section is deliberately not copied: this runtime already enforces that as a three-turn fingerprint check in code rather than as prompt text.

Design note in docs/design/2026-09-07-goal-continuation-budget.md.

Reviewer Test Plan

How to verify

cd packages/core && npx vitest run src/goals/goal-continuation-prompt.test.ts src/goals/goal-runtime.test.ts
 Test Files  2 passed (2)
      Tests  170 passed (170)

The prompt tests pin complete production-shaped ordinary and wind-down prompt strings. They also cover with-budget, no-budget, and overspent cases; require the Token budget: line to stay outside the untrusted data block and before the objective-updated notice; and distinguish first-turn, later-turn, and wind-down guidance.

Each host has coverage proving that usage survives its queue copy. The shared GoalContinuationUsage projection keeps the copied fields aligned with GoalRecord, while the host tests catch an omitted copy without making the public usage input mandatory.

cd packages/cli && npx vitest run src/ui/hooks/useMessageQueue.test.ts src/nonInteractiveCli.test.ts src/acp-integration/session/Session.test.ts src/ui/hooks/use-llm-stream.test.tsx src/ui/utils/resumeHistoryUtils.test.ts

Evidence (Before & After)

The numeric values below came from a real session transcript. The examples use the current renderer's Token budget: label, so they describe the code under review without claiming that the historical transcript already used the renamed label.

Before. The released prompt had no runtime token-budget line and ended at the standing objective guard.

After. Three continuation turns of one Goal, figures rising as it works:

Token budget: 0 of 30,000,000 tokens used, 30,000,000 remaining; 0 Goal turns finished.
Token budget: 41,634 of 30,000,000 tokens used, 29,958,366 remaining; 1 Goal turn finished.
Token budget: 85,498 of 30,000,000 tokens used, 29,914,502 remaining; 2 Goal turns finished.

The tail of that third prompt in full:

The objective in that data block is the current one and supersedes any other Goal objective text in this conversation.
Token budget: 85,498 of 30,000,000 tokens used, 29,914,502 remaining; 2 Goal turns finished.
Treat the workspace and this turn's tool results as authoritative. Re-inspect state rather than relying on what earlier turns in this conversation reported.
Work toward the end state the objective asks for. Do not substitute a narrower or more easily reached result, and do not redefine success around what already exists.
Judge your previous Goal turn before acting: it made progress only if it changed the workspace or produced evidence that changes what to do next. If it did not, take a different concrete action now instead of restating status; if the same blocker still stands, cite it through update_goal rather than repeating it.
Before proposing that the Goal is complete, check every explicit requirement in the objective against evidence you can cite. Missing, indirect, or self-reported evidence means not done: keep working.

After — the wind-down hand-off, forced by setting model.goalTokenBudget to 30000. The remainder is clamped, and the progress lines are gone:

Token budget: 62,026 of 30,000 tokens used, 0 remaining; 1 Goal turn finished.
The autonomous token budget for this Goal window is spent. This is the final turn before the Goal stops and waits for the user; do not start new work.
Deliver a concise hand-off: what was accomplished, citing evidence references from get_goal; what remains; and the one concrete next step. Call update_goal only if the objective is already complete or genuinely blocked on the evidence you have. Then end the turn.

That turn's answer closed with "the token budget is exhausted (62,026 of 30,000), so this Goal now stops and waits for the user to resume it", which is the model reading the figures and using them.

Tested on

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

Environment (optional)

Linux, npm run build, headless --output-format text against a live model, prompts read back out of the session transcript.

Risk & Scope

  • Main risk or tradeoff: this is a prompt change on every autonomous turn, so it changes model behaviour in ways unit tests cannot settle. An ordinary prompt grows from 11 to 15 lines without figures and 16 with them; when supplied usage identifies turnCount === 0, the previous-turn judgement line is omitted. The counterweight is that these lines only reach runtime-scheduled turns, never a user's own.
  • Not validated / out of scope: whether the progress lines measurably reduce idle turns, which needs longer-running Goals than an E2E run. The runtime's own idle bound is separate work. The get_goal and update_goal descriptions are unchanged.
  • Breaking changes / migration notes: none. usage remains optional; a host that omits it gets no numeric Token budget: line and receives all four non-wind-down progress checks because no first-turn signal is available.

Linked Issues

Part of #4228.

中文说明

这个 PR 做了什么

现在每个由运行时调度的 Goal 轮次,在有用量数据时都会说明已用 token、额度、剩余量和已完成轮数。非 wind-down 轮通常带四行常驻进展指引;只有传入的用量明确表明 turnCount === 0 时,因为没有上一轮可判断,才带三行。

这四行分别要求它:把工作区和本轮的工具结果当作权威,而不是听信更早的轮次报告了什么;朝着目标要求的终态去做,而不是换一个更快够得着的更窄结果;在花掉这一轮之前先判断上一轮到底有没有改变什么;以及在提出 Goal 已完成之前,把目标里每一条明确要求都对着可引用的证据核一遍。判断那一行在 Goal 的第一轮上会被跳过——create 会在任何一轮结束之前就调度出这一轮,那里根本没有"上一轮"可判断,硬要它判断只会诱使它编一个出来。

位置是刻意的:数字排在常驻目标守卫之后、"目标已变更"通知之前;它位于数据块之外,因为数据块承载明确标记为不可信的任务数据,而数字属于可信的运行时上下文。剩余量夹到 0,因为 wind-down 轮开始时窗口可能已经超支。

那一轮的交接被告知不要开始新工作,所以四行指引在它上面被跳过:一句"现在换一个具体动作"会和它直接打架。预算行则保留,因为交接本来就要报出它停在哪个数上。

usage 在公开 host 契约上仍然可选;缺失时不渲染 Token budget: 数字行,并因为没有轮次信息而在非 wind-down 轮渲染全部四行进展指引。renderer 与各 host 队列共享由 GoalRecord 投影出的 GoalContinuationUsage 类型,host 测试覆盖每一次队列拷贝。

为什么需要

模型看不到自己的额度窗口还剩多少。Goal 在 tokensUsed 达到 tokenBudget(默认 30,000,000)时停下,并获得一轮 wind-down 用来交接。在那一轮到来之前完全没有信号,所以模型分不清自己是长跑中的第 3 轮,还是预算即将把它停掉的前一轮,也就无从在"展开一次大范围调查"和"把手上的事收尾"之间做选择。get_goal 可以提供这些数字,但那需要额外一次工具调用,也不能让每个调度轮次一开始就看到额度。

它也从来没有被要求判断上一轮到底完成了什么。verifier 只会看到终态提案,所以一个什么都不提案的轮次没有任何人来判定;而花在复述状态上的轮次,恰恰就是什么都不提案的那种轮次。

Codex 的续跑模板有 56 行,把这两件事都覆盖了:同样的预算数字,加上 work-from-evidence、no-progress、fidelity 和 completion-audit 几段。本 PR 做的是其中适用部分的等价物。blocked-audit 那段刻意没有照搬:本运行时已经把它作为三轮指纹检查在代码里执行了,而不是写成提示词。

设计说明在 docs/design/2026-09-07-goal-continuation-budget.md。

评审验证方式

如何验证

cd packages/core && npx vitest run src/goals/goal-continuation-prompt.test.ts src/goals/goal-runtime.test.ts
 Test Files  2 passed (2)
      Tests  170 passed (170)

提示词测试钉住了生产形态的普通轮和 wind-down 轮完整文本;同时覆盖有额度、无额度、已超支三种情况,要求 Token budget: 行位于不可信数据块之外且在"目标已变更"通知之前,并区分第一轮、后续轮和 wind-down 轮的指引。

每个 host 都有用例证明 usage 能通过它的队列拷贝。共享的 GoalContinuationUsage 投影让字段与 GoalRecord 保持一致,host 测试则在不把公开 usage 输入改成必填的前提下捕获遗漏拷贝。

cd packages/cli && npx vitest run src/ui/hooks/useMessageQueue.test.ts src/nonInteractiveCli.test.ts src/acp-integration/session/Session.test.ts src/ui/hooks/use-llm-stream.test.tsx src/ui/utils/resumeHistoryUtils.test.ts

证据(Before & After)

下面的数字来自真实会话记录;示例标签使用当前 renderer 的 Token budget:,因此反映待评审代码,而不声称历史记录当时已经使用改名后的标签。

改动前。 已发布版本的提示词没有运行时 token 额度行,到常驻目标守卫那一行就结束了。

改动后。 同一个 Goal 的三个续跑轮,数字随着它干活往上走:

Token budget: 0 of 30,000,000 tokens used, 30,000,000 remaining; 0 Goal turns finished.
Token budget: 41,634 of 30,000,000 tokens used, 29,958,366 remaining; 1 Goal turn finished.
Token budget: 85,498 of 30,000,000 tokens used, 29,914,502 remaining; 2 Goal turns finished.

第三段提示词的结尾全文:

The objective in that data block is the current one and supersedes any other Goal objective text in this conversation.
Token budget: 85,498 of 30,000,000 tokens used, 29,914,502 remaining; 2 Goal turns finished.
Treat the workspace and this turn's tool results as authoritative. Re-inspect state rather than relying on what earlier turns in this conversation reported.
Work toward the end state the objective asks for. Do not substitute a narrower or more easily reached result, and do not redefine success around what already exists.
Judge your previous Goal turn before acting: it made progress only if it changed the workspace or produced evidence that changes what to do next. If it did not, take a different concrete action now instead of restating status; if the same blocker still stands, cite it through update_goal rather than repeating it.
Before proposing that the Goal is complete, check every explicit requirement in the objective against evidence you can cite. Missing, indirect, or self-reported evidence means not done: keep working.

改动后 —— wind-down 交接轮,通过把 model.goalTokenBudget 设成 30000 触发。剩余量被夹住,指引行消失:

Token budget: 62,026 of 30,000 tokens used, 0 remaining; 1 Goal turn finished.
The autonomous token budget for this Goal window is spent. This is the final turn before the Goal stops and waits for the user; do not start new work.
Deliver a concise hand-off: what was accomplished, citing evidence references from get_goal; what remains; and the one concrete next step. Call update_goal only if the objective is already complete or genuinely blocked on the evidence you have. Then end the turn.

那一轮的回答结尾是"the token budget is exhausted (62,026 of 30,000), so this Goal now stops and waits for the user to resume it",也就是模型确实读到了这些数字并用上了。

测试平台

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

环境(可选)

Linux,npm run build,对真实模型跑 headless --output-format text,提示词从会话记录里回读。

风险与范围

  • 主要风险或权衡:这是一次落在每个自主轮上的提示词变更,它会以单测无法定论的方式改变模型行为。普通提示词从 11 行变为无数字时 15 行、有数字时 16 行;只有传入的用量明确表明 turnCount === 0 时才少掉"判断上一轮"那一行。对冲的一面是:它们只会出现在运行时调度的轮次上,永远不会出现在用户自己的轮次里。
  • 未验证 / 范围外:指引行是否能可测量地减少空转轮,这需要比一次 E2E 更长的 Goal 才能说清。运行时自己的空转闸是单独的工作。get_goal 与 update_goal 的描述没有改动。
  • 破坏性变更 / 迁移说明:没有。usage 仍然可选;不提供它的 host 不渲染 Token budget: 数字行,并因为没有首轮信号而在非 wind-down 轮收到全部四行进展指引。

关联 Issue

属于 #4228 的一部分。

…ation prompt

The continuation prompt told the model what the objective was and how to
deliver it, and nothing about two things it had no other cheap way to know.

It could not see how much of the spend window was left. A Goal stops when
`tokensUsed` reaches `tokenBudget`, 30,000,000 by default, and gets one
wind-down turn to hand off; until that turn arrives there was no signal at
all, so the model could not tell turn 3 of a long run from the turn before the
budget stops it, and could not choose between opening a broad investigation
and finishing what it had. `get_goal` does not carry the figures either, so
there was not even an expensive way to ask.

It was also never asked whether its last turn accomplished anything. The
verifier only ever sees a terminal proposal, so a turn that proposes nothing is
judged by nobody -- and a turn spent restating status is exactly the turn that
proposes nothing.

Each runtime-scheduled turn now opens with what has been spent, out of what,
what remains, and how many turns are behind it, followed by four standing
lines: treat the workspace rather than the conversation as authoritative; work
toward the end state the objective asks for rather than a more easily reached
one; judge whether the previous turn actually changed anything before spending
this one; and check every requirement against citable evidence before
proposing completion.

The figures sit after the standing objective guard and before the
objective-updated notice, which is about what changed since the last turn and
so reads last. They stay out of the data block on purpose: that block is
compared by content to decide whether the objective changed, and a number that
moves every turn would make every turn look like an edit. The remainder is
clamped at zero, since the hand-off turn runs with the window already
overspent.

The progress lines are skipped on that hand-off turn, which is told not to
start new work -- a line asking for "a different concrete action now" would
contradict it. The budget line stays, because a hand-off reports the numbers it
stopped at.

`usage` is optional on the host contract, so a host with no figures renders
exactly the prompt it did before. The three hosts copy it through their queue
entries alongside the fields they already copy.
…dgement on turn one

The three host copies were the only link in the chain with nothing behind
them: `usage` is optional on both sides of every hop, so deleting a copy
typechecks and costs the prompt its budget line on that host alone. One
case per host now fails when its copy is removed.

The judge-your-previous-turn line is held back on the Goal's first turn.
`create` schedules a continuation before any turn has finished, so that
line asks the model to judge a turn that does not exist. A host that
reports no figures still gets the line: not knowing the turn number is
not evidence of a first turn.
@qqqys

qqqys commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Both open points taken, in 109c1e6.

The host pass-throughs now have assertions. You are right that this was the one link with nothing behind it, and that an unchanged suite is the tell. One case per host: useMessageQueue.test.ts claims a queued turn and reads the figures back, use-llm-stream.test.tsx asserts the synthetic prompt carries the budget line, and nonInteractiveCli.test.ts and Session.test.ts each drive a real startGoalTurn and assert the same line reaches the model. I removed each copy in turn and confirmed the matching case fails; with all four copies in place they pass.

The turnCount: 0 turn no longer asks for the judgement. Your call to make, and the wording really does not apply there — worse, asking a model to judge a turn that does not exist is an invitation to describe one. Judge your previous Goal turn is now held back when the figures say turnCount === 0; the other three progress lines and the budget line stand. A host that supplies no figures still gets all four, since not knowing the turn number is not evidence of a first turn. Two cases pin both sides, and the design note says why.

On splitting the budget line from the progress lines: I left them together. They ship as one prompt, the E2E evidence is one transcript, and either half is a one-line revert in the same file. If either misbehaves the revert is cheap, and splitting now would mean two prompt-text PRs racing on the same five whole-prompt expectations.

Merge order, unchanged: this needs to land after #11239 and #11248. It shares goal-runtime.test.ts with the first (which also renames tokenLedger to ledger, so those two fixtures need updating on the merge) and a goals.md anchor with the second.

中文说明

两个开放问题都采纳了,在 109c1e6。

host 侧的透传现在有断言了。 你说得对,那是整条链上唯一没有东西兜着的一环,而"测试套原样通过"本身就是破绽。每个 host 一个用例:useMessageQueue.test.ts 认领队列里的轮次并把数字读回来,use-llm-stream.test.tsx 断言合成提示词里带着预算行,nonInteractiveCli.test.ts 和 Session.test.ts 各自真正驱动一次 startGoalTurn 并断言同一行到达了模型。我把四处拷贝逐个删掉验证过,对应的用例都会失败;四处都在时全部通过。

turnCount: 0 那一轮不再要求做判断了。 这确实该由我定,而那句措辞在那一轮上根本不适用——更糟的是,让模型去判断一个不存在的轮次,等于邀请它编一个出来。现在当数字表明 turnCount === 0 时,Judge your previous Goal turn 会被跳过;另外三行指引和预算行保留。不提供数字的 host 仍然拿到四行,因为"不知道这是第几轮"并不等于"这是第一轮"。两个用例把两边都钉住了,设计文档里写了原因。

关于把预算行和指引行拆开:我没有拆。它们作为同一段提示词上线,E2E 证据也是同一份记录,而任何一半都是同一个文件里的一行回滚。真出问题回滚很便宜;现在拆开反而会让两个改提示词的 PR 在同样那五处整段断言上互相排队。

合并顺序不变:本 PR 要排在 #11239 和 #11248 之后。它与前者共用 goal-runtime.test.ts(前者还把 tokenLedger 改名为 ledger,所以合并时那两处夹具要跟着改),与后者共用 goals.md 的同一个锚点。

@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Both main (QwenLM#11248) and this branch inserted a new paragraph at the same point in the Goals introduction. The two describe different surfaces and are both kept: main's covers the footer pill and status cards rendering spend against the budget window, this branch's covers the figures and standing guidance the model receives at the start of each autonomous turn. Main's paragraph goes first because it defines "the window it is allowed" and names model.goalTokenBudget, which the second paragraph then relies on.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge: PR #11257 ← main

Merge commit 1709ae7808. One file conflicted, one block.

Root cause. Main's 50b33052cd (PR #11248, the Goal spend display) added a paragraph to docs/users/features/goals.md exactly where this PR added one — after the /trust sentence. Two insertions on one line, no shared anchor. Main's other hunks there (#11284) merged cleanly. No code file conflicted.

Textual, not semantic. Both paragraphs kept, main's first; they cover different surfaces — main's the footer pill and status cards, ours the figures and guidance the model gets at each autonomous turn's start. nonInteractiveCli.ts, the only code file both touched, auto-merged: main edited formatGoalState, ours the headless host turn record. Both present in the result.

Load-bearing.

  • Order: main's paragraph defines "the window it is allowed" and names model.goalTokenBudget; ours reuses that wording, so it must come second.
  • Keep each paragraph one long line: .prettierrc.json sets no proseWrap, so Prettier preserves rather than rewraps.
  • usage is optional at every hop, so a dropped copy typechecks and silently costs that host its Budget: line. All four forwarding sites survive; AppContainer.tsx needs none as it spreads enqueueGoalTurn(input) — an edit that enumerates fields instead must re-add it. Main added no fourth host or render site.
  • Two independent gates: the budget line prints whenever usage exists (including turnCount === 0); NO_PROGRESS_LINE is suppressed only at turnCount === 0. Do not collapse them.

Not verified — no build, typecheck, lint, or tests run.

  1. Doc tension left as-is on purpose: main says a Goal that has not billed a turn "shows no figures at all", ours says each autonomous turn begins with them. Not a contradiction — the display gates on turnCount > 0 / tokensUsed > 0, the prompt only on usage — but a reader may read it as one, and fixing it means editing main's paragraph.
  2. feat(goal): show what a Goal has spent against the window it is allowed #11248 also added tests to nonInteractiveCli.test.ts, the file this PR extends. Auto-merged, no shared fixtures, and this PR does not change TEXT output — but I could not run them.
中文说明

合并提交 1709ae7808。仅一个文件、一个冲突块。

根因:main 的 50b33052cd(PR #11248)在 docs/users/features/goals.md 与本 PR 同一处新增一段(均在 /trust 那句之后),同行两处插入、无共同锚点;main 其余改动(#11284)已干净合并。无代码文件冲突。

文本冲突,非语义冲突:两段都保留、main 在前——main 讲底部状态条与卡片显示,本 PR 讲模型在每个自动回合开头收到的数字与指引。双方都改过的 nonInteractiveCli.ts 自动合并(main 改 formatGoalState,本 PR 改无头 host 回合记录),两边都在。

关键点:main 那段先定义「the window it is allowed」并点出 model.goalTokenBudget,本 PR 沿用,故须在后;每段保持单行长文本(未设 proseWrap,Prettier 不重排);usage 每跳皆可选,漏拷贝仍过类型检查、只让该 host 丢掉 Budget: 行——四处透传点均完好,AppContainer.tsx 整体透传无需改动,改为逐字段枚举须补回;main 未新增第四个 host 或渲染点;预算行与 NO_PROGRESS_LINE 条件独立(后者仅在 turnCount === 0 时抑制),勿合并。

未能验证(未跑构建、类型检查、lint、测试):

  1. 刻意未改的观感矛盾:main 称未计费回合的 Goal「完全不显示数字」,本 PR 称每个自动回合都以数字开头。实际不冲突(显示看 turnCount > 0/tokensUsed > 0,提示词只看 usage),但调和需改 main 段落。
  2. feat(goal): show what a Goal has spent against the window it is allowed #11248 也往 nonInteractiveCli.test.ts 加用例,与本 PR 同文件;已自动合并、不共用 fixture,本 PR 不改 TEXT 输出,但无法运行验证。

@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Local verification at 7bf27d5c — round 4, delta only

Verdict: merge-ready. I rebuilt the change locally in two real worktrees and drove it end to end against a scripted model. Every claim the PR makes about its own behaviour holds at this head, all six findings the sandboxed round left open on 1709ae78 are closed except the one that was never a defect, and the mutation matrix is clean.

This is the fourth pass on this PR (triage stages 1–3, the sandboxed verification at 1709ae78, and a round-3 /review at this head), so I only report what those did not cover:

  • the head moved since the sandbox round — 8e399957 renamed the label, reworded the comment, added the design note and corrected the description, and none of that was verified;
  • a live end-to-end run, which the sandbox round explicitly listed under Not covered ("no credentials in this sandbox … the PR's transcript evidence was not independently reproduced"). I reproduced it, on all three production hosts;
  • the /goal resume path, an injection probe against the new trusted line, and a census proving user turns never carry it.

Setup

Two worktrees at 7bf27d5c (head) and 663e55bd (the main tip it merges), each fully built (node scripts/build.js --cli-only && npm run bundle) — Linux, Node v22.22.2. Model turns are served by the repo's own integration-tests/fake-openai-server.ts billing a fixed 41,634 total_tokens per turn, so the ledger is deterministic; no credentials and no live provider are involved. Control validity: the head bundle contains the string Token budget:, the base bundle contains it zero times, and the two source arms load different module bytes (prompt 98f0a364 vs c794bb38, runtime e21c6e93 vs de7959a2). Base renders 11 lines / 1,184 chars whether or not usage is handed to it, so it is a control that genuinely ignores the field rather than a partial build.

1. The central claim, measured on the wire

Same objective, same scripted model, model.goalTokenBudget = 100000; only the build differs. Observation point is the HTTP request body the CLI actually sends, not the usage object at the host boundary.

wire A/B of one continuation prompt

Every scheduled turn of that Goal, end to end, exit 0:

the budget ladder across four scheduled turns

The figures equal the runtime's own ledger at every step, the first turn carries three progress lines and every later turn four, and the hand-off carries none. The same ladder came out of all three production hosts independently — headless (nonInteractiveCli.ts), the interactive TUI (useMessageQueue.ts → use-llm-stream.ts) and ACP over stdio (Session.ts) — each driven as a separate real process.

On base the same run produces byte-identical turn-1 and turn-2 prompts (1,184 chars both). That is the problem this PR fixes, stated as a measurement: on main nothing in the prompt distinguishes one scheduled turn from the next.

Sizing, since Risk & Scope asks a reviewer to weigh it: an ordinary continuation grew 1,184 → 2,107 chars (+923, +78%) and the Goal's first turn 1,184 → 1,789. One thing that paragraph does not say, and I think should: a continuation stays in the conversation like any other user message, so a long Goal carries every one of these prompts rather than only the current one. Real, bounded, and I did not measure how it interacts with compaction.

2. The clamp, on a genuinely overspent window

wind-down hand-off A/B

124,902 spent against 100,000 granted renders 0 remaining, not -24,902, and the four progress lines are gone from the one turn that is told not to start new work. The hand-off is the only turn where the two arms differ by exactly one line.

3. Across a /goal resume — not covered by any earlier round

resume window

Two headless runs in one session. The resume grants a fresh window on top of what was already spent, exactly as docs/users/features/goals.md describes it, and the prompt line reports the raised ceiling correctly (166,536 of 266,536 … 100,000 remaining) with turnCount continuing 4 → 7. The first-turn exception is not misapplied here: turn 4 is the first turn of the resumed run but the fourth of the Goal, so the judgement line correctly stands.

4. The interactive host, for real

real TUI run

Worth stating plainly: that frame is byte-identical between the two arms (md5 fa894d0f2b71806b4a563789761555a6 on both). The change is invisible on screen and lives entirely in what the host puts on the wire, which is where I measured it.

5. Where the earlier findings stand at this head

# earlier finding status at 7bf27d5c
triage stage 2 Budget: collides with the objective field this repo documents closed — renamed Token budget:. Rendering an objective that itself contains Budget: stop as blocked after 20 turns puts that text inside the JSON data block and the runtime's own line outside it; the two never read as the same field.
verify 1 a committed comment states a mechanism that does not exist closed — the docblock now gives the trust-boundary reason, which is the one that holds.
verify 2 "a host that omits usage renders the previous prompt exactly" closed — the body now says the four progress checks still render. Measured: 11 → 15 lines with no figures.
verify 3 risk sizing understated (five lines added to a prompt "that was seventeen") closed — the body now says 11 → 15 without figures and 16 with. An exhaustive 16-combination census gives base 11–15 lines and head 13–18; the two stated numbers are exact.
verify 4 test plan predicts 168 passed closed — the body says 170; I measured Tests 170 passed (170).
verify 5 the design note the body cites is not in the PR closed — docs/design/2026-09-07-goal-continuation-budget.md is in the diff, and its account of placement, the two exceptions and the projection type matches the code I read.
verify 6 fractional tokensUsed renders 41,634.5 unchanged, and correctly so — see N1.

6. Mutation matrix at this head

mutation matrix

13/13 killed, zero survivors, including the newly renamed label and three same-file positive controls that remove a pre-existing windDown copy — without those, "the mutant survived" and "my command never collected your suite" would be the same observation. Tree asserted clean before the run, after every restore and at the end.

7. Two checks nobody had run

The new line cannot be forged from untrusted text. The budget line is the one piece of trusted context that sits outside the data block, so I fed four hostile objectives through the renderer — an embedded newline plus a fake Token budget: line, a CRLF variant, a </goal_runtime_data> close followed by a forged guard and figure, and a literal \n. All four render exactly one Token budget: line, always the runtime's own, always outside the block; line count stays 16 in every case. JSON.stringify keeps the objective on one line and escapeJsonTagCharacters handles the tag.

The lines never reach a user's turn. Census over a 129-request run with an active Goal: 103/103 runtime-scheduled turns carry the budget line, and 0/26 of everything else does — including get_goal tool results and an ordinary user message sent on --continue while the Goal was active.

8. Gates

gate result
packages/core — the two suites from the test plan Tests 170 passed (170)
packages/cli — the five suites from the test plan 1377 passed | 1 skipped (1378), 102 s
npm run typecheck (repo-wide) exit 0
prettier --check on all 15 changed files clean
eslint --max-warnings 0 on the 13 changed TS files clean
CI on this head 23 pass, 0 fail, 26 skipping

Not covered

Live provider traffic (the model was scripted, so this reproduces the prompt bytes and the runtime's arithmetic, not a real model's reaction to the figures); macOS and Windows, which the PR marks ⚠️ — formatting is pinned to toLocaleString('en-US') so it does not follow the host locale; the repo-wide suite and the integration suites; and whether the progress lines measurably reduce idle turns, which the PR scopes out itself.

Non-blocking notes

N1 — fractional spend still renders decimals, and I would leave it. isNonNegativeNumber admits a fractional tokensUsed, which reaches toLocaleString as Token budget: 41,634.5 of …. takeGoalTurnTokens sums integer totalTokenCount values, so nothing shipped can produce it. Guarding it would add a branch no test can reach honestly.

N2 — nothing pins the whole chain permanently. The runtime tests assert the usage object, the prompt tests assert the text, the host tests inject at the host seam; no fixture drives runtime → host → rendered text. My E2E closes that for this round only. A single integration fixture that drives the real runtime and asserts on the rendered string would keep it closed.

N3 — the usage === undefined branch is unreachable from every shipped host, since flushContinuation populates usage unconditionally. The description and design note are right about what happens there, but a reader may take it for a live configuration; one clause in the design note saying only an external embedder can reach it would settle that.

N4 — the projection is one rename away from silence. GoalContinuationUsage = Pick<GoalRecord, …> is the right call and fixes the drift the earlier rounds worried about, but a field renamed on GoalRecord still costs the line rather than failing the build. Already raised in round 3; I mention it only because I looked and agree it is a suggestion, not a blocker.

中文版 — 在 7bf27d5c 上的本地验证(第 4 轮,仅增量)

结论:可以合入

我在本地用两个真实 worktree 重新构建了这次改动,并对着脚本化模型跑了完整的端到端。PR 对自身行为的每一条说法在这个 head 上都成立;沙箱轮在 1709ae78 上留下的 6 条发现,除了那条本来就不是缺陷的以外全部关闭;变异矩阵干净。

这是本 PR 的第 4 轮(triage stage 1–3、1709ae78 上的沙箱验证、以及本 head 上的第 3 轮 /review),所以我只报告它们没有覆盖的部分:

  • 沙箱轮之后 head 变了 —— 8e399957 改了标签、重写了注释、补了设计文档、修正了描述,这些都没被验证过;
  • 真实端到端运行,沙箱轮在 Not covered 里明确写着没做("沙箱里没有凭据……PR 的 transcript 证据没有被独立复现")。我复现了,而且是在三个生产 host 上都复现;
  • /goal resume 路径、针对新增可信行的注入探针,以及"用户自己的轮次绝不携带这些行"的普查。

环境

两个 worktree:head 7bf27d5c、base 663e55bd(它合入的 main tip),各自完整构建(node scripts/build.js --cli-only && npm run bundle)—— Linux、Node v22.22.2。模型轮由仓库自带的 integration-tests/fake-openai-server.ts 提供,每轮固定计 41,634 total_tokens,账本因此是确定的;全程无凭据、不接真实 provider。对照有效性:head bundle 里有字符串 Token budget:,base bundle 里出现 0 次;两个源码臂加载的模块字节也不同(prompt 98f0a364 vs c794bb38,runtime e21c6e93 vs de7959a2)。base 无论是否收到 usage 都渲染 11 行 / 1,184 字符,所以它是真正忽略该字段的对照,而不是一个构建不全的臂。

1. 核心主张,在线上测得

同一个目标、同一个脚本模型、model.goalTokenBudget = 100000,只有构建不同。观测点是 CLI 真正发出的 HTTP 请求体,而不是 host 边界上的 usage 对象。

wire A/B

那个 Goal 的每一个调度轮,端到端,退出码 0:

budget ladder

数字与运行时自己的账本每一步都相符;第一轮 3 行指引、之后每轮 4 行、交接轮 0 行。同一条阶梯在三个生产 host 上各自独立地跑出来了 —— headless(nonInteractiveCli.ts)、交互式 TUI(useMessageQueue.ts → use-llm-stream.ts)、以及 stdio 上的 ACP(Session.ts),每个都是单独的真实进程。

base 侧同样的运行里,第 1 轮和第 2 轮的提示词逐字节相同(都是 1,184 字符)。这就是本 PR 要解决的问题的度量化表述:在 main 上,提示词里没有任何东西能区分相邻两个调度轮。

体量方面(Risk & Scope 要求评审者权衡这一点):普通续跑轮从 1,184 涨到 2,107 字符(+923,+78%),Goal 首轮 1,184 → 1,789。那一段没说、但我认为应该说的一点:续跑提示词会像其他用户消息一样留在对话里,所以一个长跑的 Goal 携带的是它们全部,而不只是当前这一条。真实、有界,且我没有测量它与压缩(compaction)之间的相互作用。

2. 真正超支时的夹取

wind-down A/B

100,000 的额度花掉 124,902,渲染出来是 0 remaining 而不是 -24,902;四行指引从这个被告知"不要开始新工作"的轮次上消失了。交接轮是两臂只差一行的唯一一轮。

3. 跨 /goal resume —— 此前任何一轮都没覆盖

resume window

同一会话里的两次 headless 运行。resume 在已花掉的量之上又授予一个新窗口,与 docs/users/features/goals.md 的描述完全一致;提示词行正确报出抬高后的上限(166,536 of 266,536 … 100,000 remaining),turnCount 从 4 连到 7。首轮豁免在这里没有被误用:第 4 轮是 resume 后那次运行的第一轮,却是这个 Goal 的第四轮,所以"判断上一轮"那行理应保留 —— 它确实保留了。

4. 交互式 host 的真实运行

real TUI

有必要挑明:这一帧在两臂之间逐字节相同(两边 md5 都是 fa894d0f2b71806b4a563789761555a6)。这次改动在屏幕上是不可见的,它完全活在 host 送上线的内容里 —— 而那正是我测量的地方。

5. 此前的发现在本 head 上的状态

# 此前的发现 在 7bf27d5c 上的状态
triage stage 2 Budget: 与仓库自己文档化的目标字段撞名 已关闭 —— 改名为 Token budget:。渲染一个本身含有 Budget: stop as blocked after 20 turns 的目标时,那段文本落在 JSON 数据块里,而运行时自己那行在块外;两者不会被读成同一个字段。
verify 1 已提交的注释写了一个并不存在的机制 已关闭 —— 注释改成了成立的那个理由(信任边界)。
verify 2 "不提供 usage 的 host 渲染出来就是原来那段提示词" 已关闭 —— 正文现在说四行进展指引仍会渲染。实测:无数字时 11 → 15 行。
verify 3 风险体量被低估("原本 17 行的提示词加了 5 行") 已关闭 —— 正文现在写的是无数字 11 → 15 行、有数字 16 行。穷举 16 种开关组合:base 11–15 行、head 13–18 行,正文这两个数字精确无误。
verify 4 验证方式里写 168 passed 已关闭 —— 正文写 170,我实测 Tests 170 passed (170)。
verify 5 正文指向的设计文档不在 PR 里 已关闭 —— docs/design/2026-09-07-goal-continuation-budget.md 已在 diff 中,其对位置、两处豁免和投影类型的叙述与我读到的代码一致。
verify 6 tokensUsed 为小数时渲染成 41,634.5 未改动,而且我认为不该改 —— 见 N1。

6. 本 head 上的变异矩阵

mutation matrix

13/13 全部被杀死,零幸存,其中包括新改名的标签,以及 3 个同文件阳性对照(删掉一个已有的 windDown 拷贝)—— 没有它们的话,"变异体幸存了"和"我的命令根本没收集到你的用例"是同一个观测。变异开始前、每次还原后、以及结束时都断言了工作树干净。

7. 两项此前无人做过的检查

新增的这一行无法被不可信文本伪造。 预算行是唯一一个位于数据块之外的可信上下文,所以我把四种敌意目标喂给渲染器 —— 内嵌换行加一条假的 Token budget: 行、CRLF 变体、</goal_runtime_data> 闭合后跟伪造的守卫行和数字、以及字面量 \n。四种情况都只渲染出一条 Token budget: 行,始终是运行时自己的那条,始终在块外;行数在每种情况下都保持 16。JSON.stringify 把目标压在一行里,escapeJsonTagCharacters 处理标签。

这些行绝不会落到用户自己的轮次上。 在一次带活跃 Goal 的 129 次请求的运行上做普查:103/103 个运行时调度轮携带预算行,其余 0/26 全都不带 —— 包括 get_goal 的工具结果,以及 Goal 活跃期间通过 --continue 发出的一条普通用户消息。

8. 门禁

门禁 结果
packages/core —— 验证方式里的两个套件 Tests 170 passed (170)
packages/cli —— 验证方式里的五个套件 1377 passed | 1 skipped (1378),102 秒
npm run typecheck(全仓) exit 0
prettier --check 全部 15 个改动文件 干净
eslint --max-warnings 0 13 个改动 TS 文件 干净
本 head 上的 CI 23 通过、0 失败、26 skipping

未覆盖

真实 provider 流量(模型是脚本化的,所以复现的是提示词字节和运行时的算术,不是真实模型对这些数字的反应);macOS 与 Windows —— PR 自己标了 ⚠️,格式固定用 toLocaleString('en-US'),不跟随宿主 locale;全仓测试与集成测试;以及指引行是否能可测量地减少空转轮,这一点 PR 自己已列为范围外。

非阻塞事项

N1 —— 小数额度仍会渲染出小数,我倾向于保持现状。 isNonNegativeNumber 允许小数 tokensUsed,它会带着小数进入 toLocaleString,渲染成 Token budget: 41,634.5 of …。但 takeGoalTurnTokens 累加的是整数 totalTokenCount,已发布的代码路径产生不出这种值。为它加守卫等于加一个没有测试能诚实覆盖到的分支。

N2 —— 没有任何东西长期钉住整条链路。 运行时测试断言 usage 对象、提示词测试断言文本、host 测试在 host 缝隙处注入;没有任何 fixture 驱动 运行时 → host → 渲染文本。我这一轮的 E2E 只在本轮把这个缺口补上了。一条驱动真实运行时并断言渲染字符串的集成 fixture 可以把它长期补住。

N3 —— usage === undefined 分支在所有已发布的 host 上都不可达,因为 flushContinuation 无条件填充 usage。描述和设计文档对那里会发生什么说得没错,但读者可能会把它当成一种现实存在的配置;在设计文档里加一句"只有外部嵌入方能走到这条分支"就能说清。

N4 —— 这个投影离一次改名只有一步之遥。 GoalContinuationUsage = Pick<GoalRecord, …> 是对的选择,也修掉了此前几轮担心的漂移;但如果 GoalRecord 上的字段被改名,代价仍然是少一行,而不是构建失败。第 3 轮已经提过;我写在这里只是因为我看过之后同意它是建议而非阻塞。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

…948/upstream-main-latest' into HEAD

# Conflicts:
#	packages/core/src/goals/goal-runtime.test.ts
wenshao
wenshao previously approved these changes Sep 8, 2026
@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge September 8, 2026 07:06
# Conflicts:
#	docs/users/features/goals.md
#	packages/core/src/goals/goal-runtime.ts
wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 8, 2026
@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Local verification at c518b774 — round 5, delta only

Verdict: merge-ready. I rebuilt this head and its merge-base in two real worktrees and drove the change end to end against a scripted model on all three production hosts. Everything the PR claims about its own behaviour holds at the merged head, the mutation matrix is clean, and the one commit added since my last pass — 4fa4be63 "refactor(goal): centralize continuation payload" — preserves the behaviour it refactors on every host.

This is the fifth pass (triage 1–3, the sandboxed round at 1709ae78, my round 4 at 7bf27d5c, and the bot's round-2 sandbox verification at 0bac4bbf), so I only report what those could not cover:

  • the refactor itself. 4fa4be63 replaced the hosts' named field copies with const { permit, ...continuation } = input, and the renderer's named forwarding with a ...hints spread. That is exactly the code every earlier round proved correct in its previous shape, and it landed after round 4;
  • the merged head. The bot verified 0bac4bbf; 28 commits of main have landed since, and three of this PR's own files took content from them (goal-runtime.ts 46 changed lines, nonInteractiveCli.ts 77, Session.ts 18). Nobody has driven that merged state;
  • the interaction with feat(goal): pause a Goal after three autonomous turns that make no progress #11239, which merged into main after round 4 and pauses a Goal after three autonomous turns that record nothing. This PR's new "judge your previous turn" line and that bound now ship together for the first time;
  • a correction to my own round-4 note N4, which was wrong.

Setup

Two worktrees at c518b774 (head) and a374d1b7 (the merge-base with main), each npm install && node scripts/build.js --cli-only && npm run bundle — Linux, Node v22.22.2. Model turns are served by a local OpenAI-compatible mock that bills a fixed 20,000 total_tokens per call and records every request body, so the ledger is deterministic; no credentials and no live provider are involved. The mock calls run_shell_command on each Goal turn, so every turn records real evidence and the run is not cut short by #11239's bound.

Control validity: the head's built CLI contains the string Token budget: exactly once, the base's contains it zero times, and an exhaustive census over 32 flag combinations gives base 11–15 lines against head 13–18. Base renders 11 lines whether or not usage is handed to it, so it is a control that genuinely ignores the field rather than a partial build.

1. The central claim, measured on the wire

One Goal, one objective, model.goalTokenBudget = 100000; only the build differs. Observation point is the HTTP request body the CLI actually sends.

A/B of one Goal's continuation prompts

On base, turns 0, 1 and 2 are byte-identical (md5 177c7efe three times). That is the problem this PR fixes, stated as a measurement: on main nothing in the prompt distinguishes one scheduled turn from the next.

The objective I used ends with Budget: stop as blocked after 20 turns. — the collision case triage stage 2 raised. It renders inside the JSON data block while the runtime's own Token budget: line sits outside it; the two never read as the same field.

2. The refactor, on all three production hosts

the same ladder from three hosts

Three separate real processes — headless (nonInteractiveCli.ts), the interactive TUI (useMessageQueue.ts → use-llm-stream.ts, driven in tmux) and ACP over stdio (Session.ts, driven by a raw ndjson client) — produce the same line counts, the same char counts and the same figures. All four changed host files are covered. The spread carries usage through every queue copy.

3. Oracle: the runtime's own ledger

ledger vs rendered figures

Read out of the session record's goal_state snapshots, not out of the thing under test. Exact at every step, including the clamp.

4. The wind-down hand-off

wind-down, base vs head

120,000 spent against a 100,000 window renders 0 remaining, not -20,000, and the four progress lines are gone from the one turn told not to start new work. The hand-off is the only turn where the two arms differ by exactly one line.

5. Interaction with #11239 — not covered by any earlier round

the no-progress pause on both arms

Same run with a model that answers with prose and calls no tool. Both arms pause on the same turn, at the same spend, for the same reason (status=paused, noProgressTurns=3). The PR does not move that bound. On head the model was asked to judge its previous turn, with rising figures, on turns 1 and 2 before the runtime pulled the plug; on base it received the same 11-line prompt three times. The two features compose the way the design note says they should.

6. Mutation matrix at this head

21/21 killed

21/21 killed, zero survivors. Four of them target the refactor specifically — dropping ...hints from the renderer, and dropping usage from each of the three host queue copies — so the seams the refactor introduced are covered by tests, not only by my run. Three same-file positive controls mutate pre-existing behaviour: without those, "the mutant survived" and "my command never collected your suite" would be the same observation. Tree asserted clean before the run, after every restore, and at the end.

7. Two checks the new plumbing has to keep true

injection probe and census

The budget line is the one trusted line outside the untrusted data block, so I fed five hostile objectives through the renderer — an embedded newline plus a forged Token budget: line, a CRLF variant, a </goal_runtime_data> close followed by a forged guard and figure, a literal \n, and a tag-shaped open. All five render exactly one budget line, always the runtime's own, always outside the block, at a constant 16 lines.

And the lines never reach a user's own turn: over 81 requests from five real runs, 68/68 runtime-scheduled turns carry the line and 0/13 of everything else does — including all nine side model calls these runs made (output-language side queries and a memory-extraction subagent) and an ordinary user message typed into the TUI while the Goal was active.

real TUI

That frame is a separate TUI run on a 400,000 budget, left open so the user message could be typed mid-Goal. The on-screen figure and the prompt figures come off the same ledger — the pill reads 440.0k/400.0k after the window is spent — and the user's own turn, visible above the composer, carries none of the new lines.

8. Gates

gate result
packages/core — the two suites from the test plan Tests 190 passed (190)
packages/cli — the five suites from the test plan 1393 passed | 1 skipped (1394), 134 s
npm run typecheck (repo-wide) exit 0
prettier --check on all 15 changed files clean
eslint --max-warnings 0 on the 13 changed TS files clean
CI on this head 23 pass, 0 fail, 26 skipping

Not covered

Live provider traffic (the model is scripted, so this reproduces the prompt bytes and the runtime's arithmetic, not a real model's reaction to the figures); the verifier path, since the scripted model never proposes a terminal state — every run ends at a limit, not at a verdict; macOS and Windows, which the PR marks ⚠️ — formatting is pinned to toLocaleString('en-US'), so it does not follow the host locale; the repo-wide suite and the integration suites; and whether the progress lines measurably reduce idle turns, which the PR scopes out itself.

Notes — none blocking

N1 — the ...hints spread is ordered so a caller's stray key outranks the permit.

the spread-ordering probe

renderGoalContinuationTurn puts ...hints after goalId, revision and objective, so a turn object carrying any of those three keys silently replaces the permit-derived values in the rendered data block. The old named-copy form could not do this. Nothing shipped reaches it — all four hosts carry only turnKey, origin, controller, modelStarted, kind, and I measured the rendered prompt byte-identical with those present — and TypeScript catches the literal form but not the variable form, which is the shape every host uses. Moving ...hints to the front of that object literal closes it in one line. Suggestion, not a blocker.

N2 — the design note now describes the pre-refactor copy shape. docs/design/2026-09-07-goal-continuation-budget.md says "The three hosts copy it into their queue entries alongside the fields they already copy" and scopes the host change as "one field copied through each host's queue entry". After 4fa4be63 no field is copied by name: the hosts spread the whole payload, and the renderer gained renderGoalContinuationTurn and the GoalContinuationTurn type, neither of which the note mentions. The note is in this PR's own diff, so it is worth a paragraph.

N3 — the Reviewer Test Plan's expected count is stale again. It predicts Tests 170 passed (170); this head yields 190 passed (190). That was 170 at 7bf27d5c and 187 at 0bac4bbf — the number moves with main, not with this PR, because goal-runtime.test.ts keeps gaining cases from other Goal work. Not a defect, but it is the one step a reviewer is asked to run.

Correction to my round-4 note N4. I wrote that GoalContinuationUsage = Pick<GoalRecord, …> leaves the projection "one rename away from silence" — that a field renamed on GoalRecord would cost the line rather than fail the build. That is wrong. I renamed tokensUsed to tokensSpent on GoalRecord and the build fails immediately at the projection itself: goal-continuation-prompt.ts(13,3): error TS2344: Type '"tokensUsed" | "tokenBudget" | "turnCount"' does not satisfy the constraint 'keyof GoalRecord', plus 82 more. The Pick is a load-bearing guard, not a silent one. Withdrawn.

Round 4's other notes stand at this head: fractional tokensUsed still renders decimals and still cannot be produced by anything shipped; no single fixture drives runtime → host → rendered text, which my E2E closes only for this round; and the usage === undefined branch remains unreachable from every shipped host, since flushContinuation populates usage unconditionally.

中文版 — 在 c518b774 上的本地验证(第 5 轮,仅增量)

结论:可以合入

我在两个真实 worktree 里重新构建了这个 head 和它的 merge-base,并在三个生产 host 上对着脚本化模型跑了完整的端到端。PR 对自身行为的每一条说法在合并后的 head 上都成立;变异矩阵干净;上一轮之后新增的那个 commit —— 4fa4be63 "refactor(goal): centralize continuation payload" —— 在每个 host 上都保持了它所重构的行为。

这是本 PR 的第 5 轮(triage 1–3、1709ae78 上的沙箱轮、我在 7bf27d5c 上的第 4 轮、以及机器人在 0bac4bbf 上的第 2 轮沙箱验证),所以我只报告它们无法覆盖的部分:

  • 重构本身。 4fa4be63 把四个 host 里逐字段的拷贝换成了 const { permit, ...continuation } = input,把 renderer 里逐字段的转发换成了 ...hints 展开。那正是此前每一轮在旧形态下证明正确的那段代码,而它是在第 4 轮之后才落下的;
  • 合并后的 head。 机器人验证的是 0bac4bbf;此后 main 上又落了 28 个 commit,本 PR 自己的三个文件也从中拿到了内容(goal-runtime.ts 46 行改动、nonInteractiveCli.ts 77 行、Session.ts 18 行)。没有人驱动过这个合并后的状态;
  • 与 feat(goal): pause a Goal after three autonomous turns that make no progress #11239 的相互作用。它在第 4 轮之后并入 main,会在三个"什么都没记录下来"的自主轮之后把 Goal 暂停。本 PR 新增的"判断上一轮"那一行与这个闸门是第一次一起出货;
  • 以及一条对我自己第 4 轮 N4 备注的更正 —— 那条是错的。

环境

两个 worktree:head c518b774、base a374d1b7(与 main 的 merge-base),各自 npm install && node scripts/build.js --cli-only && npm run bundle —— Linux、Node v22.22.2。模型轮由本地 OpenAI 兼容 mock 提供,每次调用固定计 20,000 total_tokens,并记录每一个请求体,因此账本是确定的;全程无凭据、不接真实 provider。mock 在每个 Goal 轮都调用 run_shell_command,所以每一轮都记录了真实证据,运行不会被 #11239 的闸门提前掐断。

对照有效性:head 构建出的 CLI 里 Token budget: 恰好出现 1 次,base 里 0 次;对 32 种开关组合的穷举普查给出 base 11–15 行、head 13–18 行。base 无论是否收到 usage 都渲染 11 行,所以它是真正忽略该字段的对照,而不是一个构建不全的臂。

1. 核心主张,在线上测得

同一个 Goal、同一个目标、model.goalTokenBudget = 100000,只有构建不同。观测点是 CLI 真正发出的 HTTP 请求体。

A/B

base 侧第 0、1、2 轮逐字节相同(md5 177c7efe 出现三次)。这就是本 PR 要解决的问题的度量化表述:在 main 上,提示词里没有任何东西能区分相邻两个调度轮。

我用的目标以 Budget: stop as blocked after 20 turns. 结尾 —— 正是 triage stage 2 提出的撞名场景。它渲染在 JSON 数据块里面,而运行时自己的 Token budget: 行在块外面;两者不会被读成同一个字段。

2. 重构,在三个生产 host 上

三个 host

三个独立的真实进程 —— headless(nonInteractiveCli.ts)、交互式 TUI(useMessageQueue.ts → use-llm-stream.ts,在 tmux 里驱动)、以及 stdio 上的 ACP(Session.ts,由一个裸 ndjson 客户端驱动)—— 给出相同的行数、字符数和数字。四个改动的 host 文件全部覆盖。展开语法把 usage 带过了每一次队列拷贝。

3. 预言机:运行时自己的账本

账本

从会话记录的 goal_state 快照里读出来,而不是从被测对象里读。每一步都精确相符,包括夹取。

4. wind-down 交接轮

wind-down

100,000 的额度花掉 120,000,渲染出来是 0 remaining 而不是 -20,000;四行指引从这个被告知"不要开始新工作"的轮次上消失了。交接轮是两臂只差一行的唯一一轮。

5. 与 #11239 的相互作用 —— 此前任何一轮都没覆盖

no-progress

同样的运行,但模型只输出散文、不调用任何工具。两臂在同一轮、同样的花费上、以同样的理由暂停(status=paused、noProgressTurns=3)。本 PR 没有移动那个闸门。在 head 侧,模型在第 1、2 轮被要求判断上一轮,并且看到了递增的数字,然后运行时才把它停下;在 base 侧,它三次收到同一段 11 行提示词。两个特性的组合方式与设计文档所说的一致。

6. 本 head 上的变异矩阵

21/21

21/21 全部被杀死,零幸存。其中 4 个专门针对本次重构 —— 从 renderer 里删掉 ...hints,以及从三个 host 的队列拷贝里各删掉 usage —— 所以重构引入的这些接缝是被测试覆盖的,而不只是被我这一次运行覆盖。3 个同文件阳性对照变异的是已有行为:没有它们的话,"变异体幸存了"和"我的命令根本没收集到你的用例"是同一个观测。变异开始前、每次还原后、以及结束时都断言了工作树干净。

7. 新管线必须保持为真的两件事

注入与普查

预算行是唯一一个位于不可信数据块之外的可信行,所以我把五种敌意目标喂给渲染器 —— 内嵌换行加一条伪造的 Token budget: 行、CRLF 变体、</goal_runtime_data> 闭合后跟伪造的守卫行和数字、字面量 \n、以及一个标签形状的开头。五种情况都只渲染出一条预算行,始终是运行时自己的那条,始终在块外,行数恒为 16。

而这些行绝不会落到用户自己的轮次上:在来自五次真实运行的 81 个请求里,68/68 个运行时调度轮携带这一行,其余 0/13 全都不带 —— 包括这些运行发出的全部 9 次旁路模型调用(输出语言 side query 和记忆抽取 subagent),以及在 Goal 活跃期间敲进 TUI 的一条普通用户消息。

真实 TUI

这一帧是另一次 TUI 运行(额度 400,000),特意留着好在 Goal 活跃期间敲进用户消息。屏幕上的数字和提示词里的数字来自同一本账 —— 额度花完后胶囊读作 440.0k/400.0k —— 而输入框上方那条用户自己的轮次,一行新内容都没带。

8. 门禁

门禁 结果
packages/core —— 验证方式里的两个套件 Tests 190 passed (190)
packages/cli —— 验证方式里的五个套件 1393 passed | 1 skipped (1394),134 秒
npm run typecheck(全仓) exit 0
prettier --check 全部 15 个改动文件 干净
eslint --max-warnings 0 13 个改动 TS 文件 干净
本 head 上的 CI 23 通过、0 失败、26 skipping

未覆盖

真实 provider 流量(模型是脚本化的,所以复现的是提示词字节和运行时的算术,不是真实模型对这些数字的反应);verifier 路径 —— 脚本模型从不提出终态提案,每次运行都停在某个限制上而不是停在一个判定上;macOS 与 Windows —— PR 自己标了 ⚠️,格式固定用 toLocaleString('en-US'),不跟随宿主 locale;全仓测试与集成测试;以及指引行是否能可测量地减少空转轮,这一点 PR 自己已列为范围外。

备注 —— 均不阻塞

N1 —— ...hints 的展开顺序让调用方的杂散键盖过 permit。

展开顺序

renderGoalContinuationTurn 把 ...hints 放在 goalId、revision、objective 之后,所以只要 turn 对象上带了这三个键中的任何一个,它就会静默替换掉由 permit 推出的值。旧的逐字段拷贝形态做不到这一点。已发布的代码路径都碰不到它 —— 四个 host 只带 turnKey、origin、controller、modelStarted、kind,我实测在它们存在时渲染结果逐字节不变 —— 而 TypeScript 只能拦住字面量形式,拦不住变量形式,偏偏后者才是每个 host 的实际写法。把 ...hints 移到那个对象字面量的最前面,一行就能关掉。建议,非阻塞。

N2 —— 设计文档描述的还是重构前的拷贝形态。 docs/design/2026-09-07-goal-continuation-budget.md 写的是 "The three hosts copy it into their queue entries alongside the fields they already copy",并把 host 侧改动的范围写成 "one field copied through each host's queue entry"。4fa4be63 之后没有任何字段是按名字拷贝的:host 展开整个载荷,renderer 新增了 renderGoalContinuationTurn 和 GoalContinuationTurn 类型,而文档两者都没提。这份文档就在本 PR 的 diff 里,值得补一段。

N3 —— 评审验证方式里的预期数字又过期了。 它写的是 Tests 170 passed (170),这个 head 实测 190 passed (190)。在 7bf27d5c 上是 170,在 0bac4bbf 上是 187 —— 这个数跟着 main 走而不是跟着本 PR 走,因为 goal-runtime.test.ts 一直在从其他 Goal 工作里获得新用例。不是缺陷,但这恰恰是评审被要求亲自执行的那一步。

对我第 4 轮 N4 备注的更正。 我当时写 GoalContinuationUsage = Pick<GoalRecord, …> "离一次改名只有一步之遥",说 GoalRecord 上的字段被改名只会少一行、不会让构建失败。这是错的。我把 GoalRecord 上的 tokensUsed 改名成 tokensSpent,构建立刻在投影本身处失败:goal-continuation-prompt.ts(13,3): error TS2344: Type '"tokensUsed" | "tokenBudget" | "turnCount"' does not satisfy the constraint 'keyof GoalRecord',另加 82 个错误。这个 Pick 是一个承重的守卫,不是一个静默的守卫。该备注撤回。

第 4 轮的其余备注在本 head 上仍然成立:小数 tokensUsed 仍会渲染出小数,而已发布的代码路径仍然产生不出这种值;没有任何单个 fixture 贯通 运行时 → host → 渲染文本,我这一轮的 E2E 只在本轮把这个缺口补上;usage === undefined 分支在所有已发布的 host 上仍然不可达,因为 flushContinuation 无条件填充 usage。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao added this pull request to the merge queue Sep 8, 2026
Merged via the queue into QwenLM:main with commit 1f89008 Sep 8, 2026
87 checks passed
qqqys added a commit to qqqys/qwen-code that referenced this pull request Sep 9, 2026
…-claim-budget-retry

Conflict in docs/users/features/goals.md: upstream added the continuation-prompt
paragraph (QwenLM#11257) immediately before the checkpoint paragraph this branch
rewrote. Keep both -- the branch's wording also corrects the stall accounting
upstream still describes as unconditional, which QwenLM#11304 made conditional on an
overflowing evidence window.
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.

4 participants