feat(goal): grant one hand-off turn before a spent budget stops the Goal - #10132
Conversation
QwenLM#9891 stops a Goal the moment its autonomous token budget is spent: the continuation gate refuses the next turn and settles usage_limited. That bounds runaway spend, but it cuts the model off mid-thought -- whatever it had learned in the last window is stranded in the transcript, and the user who resumes gets no hand-off. A spent window now buys exactly one more continuation, flagged `windDown`, whose prompt says the budget is spent, forbids new work, and asks for a concise hand-off: what was accomplished with evidence refs, what remains, the one concrete next step. When that turn finishes, the gate stops the Goal as before. A hand-off turn that finds the objective already met and proposes completion still completes the Goal: the stop only ever refuses a continuation, never a verdict. Exactly one per window, and persisted: the wind-down turn's own turn_finished record stamps `GoalRecord.windDownTurnId`, so the gate can tell "hand-off delivered" from "hand-off owed" across a restart with no extra journal write and no new state cause. A hand-off the host dropped undelivered leaves no marker and is minted again; a restart that interrupted the hand-off turn grants it again for the same reason -- the user never got one. Re-arming the budget on resume or edit clears the marker, so each window owes its own. The flag rides the host boundary like verifierFeedback, through all three hosts, and the prompt block sits after the authoritative objective line and above verifier feedback; the ordinary prompt is byte-identical to before. Mutation probes (goal-runtime + goal-reducer, 218 tests): finishTurn never stamps the marker -> 3 fail; gate ignores the marker -> 4; gate never grants -> 6; re-arm keeps the old marker -> 3; parse never restores it -> 2. Each host hop deleted -> exactly one test fails in that host's suite (useMessageQueue, useGeminiStream, nonInteractiveCli, Session).
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR!
Moving on to code review. 🔍 中文说明感谢贡献!
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewI traced this end-to-end against the merged #9891 machinery, and the design holds up. My independent read of the problem would have landed on the same shape: persist the "hand-off delivered" fact on the wind-down turn's own What I verified by reading the runtime, not just the diff:
No critical issues, no convention violations — the comments explain genuinely non-obvious ordering constraints, and the 4:1 test-to-production ratio is doing real work. One observation, non-blocking: the mutation-resistance numbers in the PR body are the author's own report and can't be re-run here, and the How the flow workssequenceDiagram
participant P1 as Model turn
participant P2 as Budget gate (queueContinuation)
participant P3 as Host (TUI, headless, ACP)
participant P4 as Reducer and journal
P1->>P2: turn ends, budget spent
alt no wind-down marker on the record
P2->>P3: grant one continuation with windDown flag
P3->>P1: hand-off prompt, no new work
P1->>P4: turn_finished stamps windDownTurnId
P4->>P2: gate re-checks, marker present
P2->>P4: stopForSpentBudget settles usage_limited
else marker already present
P2->>P4: stopForSpentBudget settles usage_limited
end
Files changed (15 of 15 shown)
Test evidence — the PR's own CICI on the reviewed commit at review time: the main Linux unit suite was still running (it takes ~30 minutes, no polling per process), preflight/secret/CVE checks passed, and the macOS/Windows/integration/tmux jobs were skipped on this fork PR. The finalize workflow updates the table below once CI settles. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Not verified here: live behavior on any platform — CI evidence above is the only test signal this pass carries, and the author's mutation-probe numbers are their own report, not independently re-run (this process never executes PR code). The change itself is pure state-machine and prompt-string logic with no platform APIs, which limits what the skipped macOS/Windows jobs could have caught. Sandboxed verification would settle the rest: Real-scenario tmux testing: N/A on this unattended CI run (no local product driving; a maintainer can trigger the isolated 中文说明代码审查对照已合并的 #9891 机制做了端到端走查,设计成立。我独立分析该问题得到的方案与之一致:把"交接已交付"这一事实持久化在收尾轮自己的 通过阅读运行时代码(而非只看 diff)确认的关键点:
无关键问题、无规范违规——注释解释的是真正不自明的顺序约束,4:1 的测试/生产比在做实事。一条非阻塞观察:PR 正文里的变异检验数字是作者自述,此处无法复跑;reducer 测试钉死的 流程示意时序图见英文正文(预算闸门 → 授予一次带 windDown 标志的续跑 → 交接轮结束盖章 → 闸门复查标记存在 → 落为 usage_limited;标记已存在时直接停止)。 测试证据审查时点上的 CI:Linux 主单元测试套件仍在运行(约 30 分钟,按流程不轮询),预检/密钥扫描/依赖 CVE 检查通过,macOS/Windows/集成/tmux 作业在本 fork PR 上被跳过。CI 落定后 finalize 工作流会更新上方表格。此处未验证任何平台的实际运行行为——本 pass 只携带上述 CI 信号;作者的变异检验数字为其自述。改动本身是纯状态机与提示词字符串逻辑、不涉及平台 API,被跳过的平台作业能捕获的风险有限。沙箱验证可以补足剩余部分: — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean, minimal, thoroughly pinned; the last point is withheld only for CI that had not settled and platform jobs this fork PR skips. Stepping back: this is what a good increment on a living subsystem looks like. The problem is real — the budget stop #9891 shipped today cuts the model off with no hand-off, and anyone resuming has to reconstruct where things stand from a raw transcript. The fix is the smallest thing that works: one granted turn, one persisted marker, one prompt block, and a flag that rides plumbing already built for The edge cases are where this kind of change usually rots, and they are the best part of the PR: restart mid-hand-off re-grants because the user never got one; a host-dropped start re-mints for the same reason; re-arming buys a fresh window with a fresh hand-off; and a completion verdict on the wind-down turn outranks the budget stop. Each is argued in the description and each is pinned by a test — the description and the code agree, which is rarer than it should be. 88 production lines carry 373 lines of tests, there are no drive-by edits, and every one of the ten gate entry points funnels through the single checked path. Reservations, all non-blocking: the mutation-resistance numbers are the author's own report; the macOS/Windows/integration jobs skipped on this fork PR leave platform coverage to the Linux suite (limited risk for pure state-machine and prompt-string logic); and the Verdict: approve. CI on the reviewed commit is still running, so approval is deferred until it lands green on 中文说明置信度:4/5 —— 干净、最小化、钉得扎实;扣掉的一分只因为审查时 CI 尚未落定,且本 fork PR 的平台作业被跳过。 退一步看:这是一个活跃子系统上好的增量。问题是真实的——今天刚合并的 #9891 的预算停止会把模型直接切断、没有任何交接,回来 resume 的人只能从原始转录里重建现状。修复是可行的最小方案:授予一轮、一个持久化标记、一个提示块,标志沿已为 这类改动通常在边界情况上腐烂,而边界情况恰是本 PR 最出色的部分:交接中途重启会重新授予(因为用户从未拿到);host 丢弃的启动同理重铸;重新武装购买新窗口、附带新的交接;收尾轮上的完成裁决优先于预算停止。每一条都在描述里论证过、又各被一个测试钉死——描述与代码一致,这比应有的更少见。88 行生产代码承载 373 行测试,没有顺手改动,十个闸门入口全部汇入唯一受检路径。 保留意见(均不阻塞):变异检验数字为作者自述;被跳过的平台作业使平台覆盖只剩 Linux 套件(对纯状态机与提示词字符串逻辑而言风险有限);reducer 钉死的 结论:批准。审查提交上的 CI 仍在运行,因此批准推迟到其在该提交上落绿——finalize 工作流会在全部检查完成后把批准钉到该提交;若有检查落红或 HEAD 移动,则不会批准。 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
|
Released in v0.22.2. |
…red (QwenLM#10260) * fix(goal): stamp the wind-down hand-off only when its turn was delivered QwenLM#10132 marked the record's `windDownTurnId` whenever the turn holding the wind-down permit finished -- reading "the permit was used" as "the user got the hand-off". QwenLM#10013 established why that inference is wrong for the objective-updated notice: a system message or a direct user query can claim a queued continuation's permit and send its own text under it, so the turn finishes with the prompt never reaching the model. Hosts therefore mark delivery at the real send site, and only a delivered turn commits what it carried. The hand-off now follows the same rule. `finishTurn` stamps the marker only when the wind-down turn was marked delivered; an undelivered one leaves the record clean, so the next `queueContinuation` grants the hand-off again instead of settling `usage_limited` on a hand-off the user never received (which a resume would not have repaired either, since the marker is cleared only by a re-arm). The in-memory permit marker is released either way; it belongs to the permit, not the outcome. The wind-down tests that finish the hand-off turn now mark it delivered first, so they keep meaning "the model saw the hand-off". Two new cases pin the split: finished-but-undelivered leaves no marker and re-mints the hand-off; finished-and-delivered stamps it and stops. Mutation probe: making the stamp unconditional again fails exactly the undelivered case (145 others green). * docs(goal): align wind-down comments with the delivered-stamp rule (QwenLM#10260) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
What this PR does
When a Goal's autonomous token budget (#9891) is spent, the runtime now grants exactly one more continuation — the wind-down turn — before it stops the Goal. That turn's prompt says the budget for this window is spent, forbids new work, and asks for a concise hand-off: what was accomplished with evidence references from
get_goal, what remains, and the one concrete next step. When the turn finishes, the gate settles the Goal asusage_limited/limitKind: 'token_budget'exactly as today. If the hand-off turn finds the objective already met and proposes completion, and the verifier accepts, the Goal completes — the budget stop only ever refuses a continuation, never a verdict.The grant is one per spend window and survives restarts. The wind-down turn's own
turn_finishedrecord stampsGoalRecord.windDownTurnId, so the gate distinguishes "hand-off delivered" (marker present → stop) from "hand-off owed" (marker absent → grant) with no extra journal write and no new state cause. A hand-off the host dropped before the model saw it leaves no marker and is minted again; a restart that interrupted the hand-off turn grants it again for the same reason — the user never got one. Re-arming the budget on resume or edit clears the marker, so each new window owes its own hand-off.The
windDownflag rides the host boundary the wayverifierFeedbackdoes —GoalTurnHost.startGoalTurn→ the queued-turn types in the TUI, headless, and ACP hosts →renderGoalContinuationPrompt— and the prompt block sits after the authoritative-objective line and above verifier feedback. The ordinary continuation prompt is byte-identical to before.Why it's needed
#9891 gave the Goal runtime its first autonomous termination path, but a hard stop at the gate cuts the model off mid-thought: whatever it learned in the last window is stranded in the transcript, and the user who comes back to resume gets no summary of where things stand. The budget is an authorization quantum — one explicit user action buys one window — so the end of a window is exactly the moment the user needs a hand-off to decide whether to buy another. This turns the budget stop from a cut into a checkpoint the user can act on.
Reviewer Test Plan
How to verify
cd packages/core && npx vitest run src/goals/— 446 tests, 16 files. The three existing budget-stop tests now walk through the hand-off (spend → one flagged turn → finish → stop, with the marker persisted on the hand-off'sturn_finishedrecord and absent from the earlier one). New: the hand-off is minted again when the host refused its start; a hand-off turn that proposes completion completes the Goal with nousage_limitedrecord; a restored record with the marker stops without minting anything; a restored record without it (restart mid-hand-off) mints the hand-off; two prompt cases (the block appears only when flagged, and stays above verifier feedback); reducer cases for stamping, clearing on resume/edit re-arm, surviving a non-re-arming resume, and persistence round-trip incl. rejecting an empty marker.cd packages/cli && npx vitest run src/ui/hooks/useMessageQueue.test.ts src/ui/hooks/useGeminiStream.test.tsx src/nonInteractiveCli.test.ts src/acp-integration— 42 files, 2148 tests; each host hop now has a test that reads the flag through to the rendered prompt.finishTurnnever stamps the marker → 3 fail; gate ignores the marker → 4; gate never grants → 6; re-arm keeps the old marker → 3; parse never restores it → 2. Deleting each host hop → exactly one test fails in that host's suite (useMessageQueue 1/40, useGeminiStream 1/233, nonInteractiveCli 1/130, Session 1/698). Every other test green in every run.npx tsc --noEmitinpackages/coreandpackages/cli: no errors in goal code (core carries pre-existing dependency-skew errors outsidesrc/goals/). prettier + eslint clean on all changed files.Evidence (Before & After)
Continuation prompt tail on the wind-down turn (the ordinary turn is unchanged):
Tested on
Environment (optional)
N/A (unit tests only).
Risk & Scope
lastReasonor the Goal status card (the stop reason stays the budget reason). Overlaps feat(goal): tell the model the objective changed, once, when it changed #10013 (B3) on the same host hops — both add an optional flag next toverifierFeedback; resolve by merging main, not rebasing.windDownTurnIdis optional; old records restore without it and are treated as owing a hand-off, which is the correct reading for a Goal that never had one.Linked Issues
中文说明
这个 PR 做了什么
当 Goal 的自主 token 预算(#9891)耗尽时,runtime 现在会在停止 Goal 之前恰好再授予一次续跑——收尾轮。该轮的提示词说明本窗口预算已耗尽、禁止开始新工作,并要求给出简明交接:完成了什么(引用
get_goal的证据引用)、还剩什么、以及唯一具体的下一步。该轮结束后,闸门像今天一样把 Goal 落为usage_limited/limitKind: 'token_budget'。如果收尾轮发现 objective 已经达成并提出完成,且 verifier 接受,则 Goal 完成——预算停止只拒绝续跑,从不推翻裁决。每个消费窗口只授予一次,且跨重启持久化。收尾轮自己的
turn_finished记录盖上GoalRecord.windDownTurnId,因此闸门能区分「交接已交付」(标记存在 → 停止)与「交接尚欠」(标记缺失 → 授予),无需额外日志写入,也无需新的状态 cause。被 host 在模型看到之前丢弃的交接不会留下标记,会再次铸造;打断了收尾轮的重启也会再次授予,理由相同——用户从未拿到交接。resume 或 edit 重新武装预算时清除标记,因此每个新窗口都欠自己的交接。windDown标志沿verifierFeedback的路径穿过 host 边界——GoalTurnHost.startGoalTurn→ TUI、headless、ACP 三个 host 的排队 turn 类型 →renderGoalContinuationPrompt——提示块位于权威 objective 行之后、verifier feedback 之上。普通续跑提示词逐字节不变。为什么需要
#9891 给了 Goal runtime 第一条自主终止路径,但在闸门处硬停会把模型截在半途:上一窗口学到的东西滞留在转录里,回来 resume 的用户得不到任何现状摘要。预算是一种授权额度——一次显式用户动作购买一个窗口——因此窗口结束恰恰是用户需要一份交接来决定是否再买一个的时刻。本 PR 把预算停止从「切断」变成用户可以据以行动的「检查点」。
评审验证计划
如何验证
cd packages/core && npx vitest run src/goals/——446 个测试,16 个文件。三个既有的预算停止测试现在走完交接流程(消费 → 一个带标志的 turn → 结束 → 停止,标记持久化在交接轮的turn_finished记录上、且不出现在更早的记录里)。新增:host 拒绝启动时交接再次铸造;交接轮提出完成时 Goal 完成且无usage_limited记录;带标记的恢复记录直接停止不铸造任何 turn;不带标记的恢复记录(交接中途重启)铸造交接;两个提示词用例(仅在置位时出现该块,且位于 verifier feedback 之上);reducer 用例覆盖盖章、resume/edit 重新武装时清除、不重新武装的 resume 保留、持久化往返含拒绝空标记。cd packages/cli && npx vitest run src/ui/hooks/useMessageQueue.test.ts src/ui/hooks/useGeminiStream.test.tsx src/nonInteractiveCli.test.ts src/acp-integration——42 个文件,2148 个测试;每个 host 跳板都有测试把标志一路读到渲染出的提示词。finishTurn从不盖章 → 3 个失败;闸门无视标记 → 4 个;闸门从不授予 → 6 个;重新武装保留旧标记 → 3 个;parse 从不恢复标记 → 2 个。删除任一 host 跳板 → 该 host 套件恰好挂 1 个(useMessageQueue 1/40、useGeminiStream 1/233、nonInteractiveCli 1/130、Session 1/698)。其余测试每次全绿。packages/core与packages/cli的npx tsc --noEmit:goal 代码无错误(core 存在src/goals/之外的既有依赖偏差错误)。所有改动文件 prettier + eslint 干净。证据(前后对比)
收尾轮的续跑提示词末尾(普通轮次不变):
已测试平台
Linux ✅;macOS / Windows⚠️ (CI 覆盖)。
环境(可选)
N/A(仅单元测试)。
风险与范围
lastReason或 Goal 状态卡上(停止原因仍是预算原因)。与 feat(goal): tell the model the objective changed, once, when it changed #10013(B3)在同一批 host 跳板上重叠——两者都在verifierFeedback旁新增一个可选标志;通过合并 main 解决,不 rebase。windDownTurnId可选;旧记录恢复时没有它,被视为尚欠交接——对从未有过交接的 Goal 而言这是正确的解读。关联 Issue