refactor(core): hold Goal sends to the caller's recursion budget, and say why the other carve-outs stay - #10126
refactor(core): hold Goal sends to the caller's recursion budget, and say why the other carve-outs stay#10126qqqys wants to merge 2 commits into
Conversation
… say why the other carve-outs stay Once a Goal had its own autonomous spend bound (QwenLM#9891), the places where client.ts carved runtime Goal turns out of the session ceilings were due for a second look. Read one at a time they are not one thing. Removed: a Goal-type send ignored the caller's `turns` budget and always started from MAX_TURNS. Every runtime continuation is a fresh top-level send that starts from MAX_TURNS on its own, and no production caller passes `turns` for a Goal send, so the carve-out only ever mattered for a caller handing over an exhausted budget -- and for that caller a Goal should be refused like every other message type. It now is, and the interrupted-exit path pauses the Goal instead of leaving it running with a permit nobody will finish. Retained, with the reason written at the site: - sessionTurnCount / maxSessionTurns. The counter increments on every model call the user's prompts drive, tool continuations included, so counting Goal turns would let a user cap of N kill a healthy Goal after ~N/4 continuations with no resume path in headless. The Goal is bounded by its own token budget, and the headless host excludes runtime Goal turns from the same cap, so counting here would split the two ceilings. - The steer-input check on the same count, for the same reason. - The legacy stop-hook Goal chain's undecremented recursion budget. That chain recurses inside one sendMessageStream call, so the runtime's token budget never meters it; it is bounded by MAX_GOAL_ITERATIONS and stopHookBlockingCap, and a 50-iteration chain with steer and next-speaker continues would exhaust MAX_TURNS before its own cap. The 75-turn runtime test no longer passes 0 as the budget; it was only passing because of the removed carve-out. Mutation probe: re-adding the carve-out fails exactly the new test (22 others green).
|
@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,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
|
Thanks for the PR! Template looks good ✓ — all sections present, bilingual, risk & scope filled in. Problem: this isn't a user bug report — it's the recorded follow-up to the Goal series plan: #9891 (the Goal token budget, merged today) set up removing the Direction: aligned. This is the cleanup half of an already-shipped design; keeping the three exemptions that still earn their place — with comments rewritten to the real reason — is the right call over blanket removal. No direct CHANGELOG reference, but this is internal Goal-runtime hygiene in line with the merged series. Size: core path touched ( Approach: minimal and focused — one exemption removed, three comments rewritten, one new test pinning the removal. Nothing to cut; the per-site verdict table in the description is exactly the reasoning a reviewer needs. One thing I'll check in the review: the interrupted-exit path the description says now pauses the Goal when its budget is refused. Risk: no elevated risk signals — neither changed file matches the revert-correlated paths. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 各节齐全,双语,风险与范围已填写。 问题:这不是用户 bug 报告,而是 Goal 系列计划记录在案的后续:#9891(Goal token 预算,今日已合并)落地后, 方向:对齐。这是已上线设计的清理部分;保留仍然站得住的三处豁免并重写注释为真实理由,比一刀切全撤更正确。CHANGELOG 无直接引用,但这属于与已合并系列一致的内部 Goal-runtime 整理。 规模:触及核心路径( 方案:最小且聚焦——撤一处豁免,重写三处注释,新增一个测试固定该移除。没有可砍的部分;描述里的逐位置结论表正是审查者需要的推理。审查中我会核对一处:描述中说预算被拒绝时中断退出路径会暂停 Goal。 风险:无升级风险信号——两个改动文件均未命中与 revert 相关的路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewI wrote down my own proposal before reading the diff — make The load-bearing claim — no production caller passes The three retained exemptions are behaviorally unchanged, and the rewritten comments match what the code actually does: the session-turn exclusion is mirrored by the headless host ( No blockers found; style and structure follow house conventions. Test evidence — the PR's own CIUnattended run — per policy I don't build or execute PR code; the evidence below is the PR's own CI on the reviewed commit, fetched via the API. No failures at time of writing; the main unit suite is still running, and the Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 The Linux job carries the unit suite for this commit; the macOS/Windows test jobs report skipped here rather than failed. I'm reporting that as fact, not diagnosing it — the table above updates when CI lands. 中文说明代码审查读 diff 之前我先写下了自己的方案——让 关键主张——生产中没有任何调用方给 Goal 发送传 三处保留的豁免行为未变,重写后的注释与代码实际行为一致:会话轮次排除在 headless 侧有镜像(nonInteractiveCli.ts:2303 的 未发现阻塞项;风格与结构符合项目惯例。 测试证据 —— PR 自己的 CI无人值守运行——按策略不构建、不执行 PR 代码;以上证据来自 API 拉取的该提交自身 CI。撰写时无失败;主单元测试仍在运行, — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 3/5 — clean review across every stage; the 3/5 is pure policy — the fork- ⏸️ Deferring to @wenshao — this is a cross-repository (fork) The review held up under skepticism. My independent proposal before reading the diff — make The one judgment left is a design call that belongs to the maintainer: whether the retained CI at review time: 中文说明置信度:3/5 —— 各阶段审查都很干净;这个 3/5 纯属政策——fork ⏸️ 转交 @wenshao —— 这是一个跨仓库(fork)的 审查经受住了怀疑。读 diff 前我的独立方案——让 剩下的一个判断是属于维护者的设计决策:保留 审查时的 CI: — Qwen Code · qwen3.8-max Reviewed at |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 39 passed · 0 failed · 39 total Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:39 通过 · 0 失败 · 39 总计 抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence Verification reportPR 10126 verification —
|
| code \ tests | base test file (22 tests) | head test file (23 tests) |
|---|---|---|
| base code (carve-out present) | 22/22 green — the old 75-turn test passes turns=0 and is rescued by the carve-out |
22 green / 1 RED: exactly holds a runtime Goal turn to the caller recursion budget like any other send, failing expected "spy" to not be called at all, but actually been called 1 times (03-base-code-fails-new-budget-test.png) |
| head code (carve-out removed) | 21 green / 1 RED: exactly runs runtime-scheduled Goal turns beyond the former fixed limit without session budgets — it passes turns=0, which head now refuses, so no second permit is ever issued |
23/23 green; full reviewer suite (cell D): 18/18 files, 837/837 tests green |
Both off-diagonal cells flip on exactly the test that encodes the change, and each failure is the behavioral assertion (model ran despite turns=0 / no turn ran), not a fixture or collection error. The carve-out removal is load-bearing both ways.
S4 census (scripted): exactly 3 GeminiClient.sendMessageStream call sites repo-wide — useGeminiStream.ts:3822 (TUI), nonInteractiveCli.ts:2337 and :2663 (headless) — none passes a turns argument (it defaults to MAX_TURNS), so every production Goal send gets Math.min(MAX_TURNS, MAX_TURNS) = the old value. ACP drives GeminiChat.sendMessageStream directly (Session.ts:7286), never this path — matching the description. The headless host's mirrored exclusion was read at nonInteractiveCli.ts:2304 (enforceSessionTurnLimit(goalTurn?.origin === 'runtime')).
S1 pinned: M2 (reverting the retained !isGoalRuntimeTurn on the increment) fails exactly 3 tests, including the 75-turn test's sessionTurnCount === 0 assertion.
Corrections
- Reviewer Test Plan step 1 says "18 files, 824 tests"; measured at this head: 18 files, 837 tests (all green). The suite grew on
mainafter the branch was cut; the 18-file count and the green result hold. Not a defect. - Step 3 says "
npx tsc --noEmit: 32 errors with and without the diff". Measured in this container: 1 error on both arms, byte-identical (TS7016on@lydell/node-ptyinshellExecutionService.ts, unrelated to the diff;tsc-base.log==tsc-head.log). The substantive claim — the diff does not move typecheck, and no error touches a changed file — holds; the absolute count is environment/branch-drift dependent.
Findings
F1 (Suggestion, completeness, pre-existing) — the retained steer-input goal exclusion (!isGoalRuntimeTurn && in takeSteerInput, client.ts ~3345) is not independently pinned: mutating it away (M3) leaves the broad suite (client.test.ts + client-goal.test.ts + goalHook.test.ts, 427 tests) fully green, because the sibling increment-exclusion keeps sessionTurnCount at 0 for Goal turns, so sessionTurnCount >= maxSessionTurns never trips in any test. The guard is still observable in production (user turns can hit the cap, then a runtime Goal turn arrives), so this is a coverage gap, not dead code. It pre-exists this PR (the PR adds only a comment at this site) and the coupled behavior is pinned via M2's three failures, so it is not a merge condition. I built the pinning fixture and measured it: a scratch test setting client['sessionTurnCount'] = 1 (mocked cap = 1) and asserting getSteerInput is called on a Goal-runtime send runs 24/24 green at head and 1 red under M3 (expect(getSteerInput).toHaveBeenCalled() fails) — the fixture to ship if the author wants the gap closed:
measured pinning fixture (append inside the `describe` in client-goal.test.ts)
it('admits a runtime Goal turn to steer input at a hit session cap', async () => {
const { client } = setupGoalClient();
turnMocks.run.mockImplementation(async function* () {});
client['sessionTurnCount'] = 1;
const getSteerInput = vi.fn().mockResolvedValue(undefined);
await collect(
client.sendMessageStream(
[{ text: 'continue' }],
new AbortController().signal,
'goal-steer-cap',
{
type: SendMessageType.Goal,
goalPermit: permit,
goalTurnKey: `goal-runtime:${permit.turnId}`,
getSteerInput,
},
),
);
expect(getSteerInput).toHaveBeenCalled();
});F2 (Suggestion, completeness, pre-existing) — the legacy hook Goal's undecremented budget (hookTurnBudget = activeGoal ? boundedTurns : boundedTurns - 1, client.ts:4181) is not pinned by any test: M4 (always decrement) leaves the same 427-test broad suite green, because the decrement only bites near MAX_TURNS recursion depth or with a small caller turns, and the census shows no production caller passes turns. Pre-existing gap on a comment-only hunk; not a merge condition. A pinning fixture would need a ~100-deep legacy stop-hook chain with an active goal (or a caller-passed small budget) asserting the chain survives past the budget the mutated code would exhaust; not built this round (budget), shape named here.
F3 (nit) — see Corrections: two Reviewer Test Plan numbers (824 tests; 32 tsc errors) do not reproduce at this head; both substantive claims hold.
No injection attempts were found in the PR text; author claims were treated as hypotheses and each was exercised.
Not covered
- ACP behavior end-to-end (census proves it never enters
GeminiClient.sendMessageStream; itsGeminiChatpath was not exercised). - M4 pinning fixture (shape named in F2; not built).
- TUI/interactive E2E — the change is unit-level and the census shows no production caller can observe it; headless
enforceSessionTurnLimitexclusion read but not driven. - Windows/macOS; repo-wide gates beyond the affected suite, core typecheck, and lint/format of the two changed files.
- Per-commit attribution: N/A — the PR is a single commit (
fa383715f7, matching the metadatacommitsarray and localHEAD^2).
Methodology
Environment: CI verify container, refs/pull/10126/merge at depth 2 (HEAD merge, HEAD^1 base tip, HEAD^2 PR head), npm ci + npm run build pre-run at HEAD. A/B cells ran vitest in scratch git worktrees under tmp/ (removed after capture); base-tree built its own packages/core/dist in-tree via scripts/build_package.js with the root node_modules on PATH (satisfying the fresh-checkout guard), and both worktrees symlinked the unchanged nested packages/core/node_modules; contamination was ruled out by the zero-bare-workspace-imports census of core src. Mutations were applied to client.ts in the main checkout with perl, each followed by git checkout -- restore and a clean-tree assertion; the unmutated controls (C0/C1) bracket the matrix. All counts in assertions.json (39 pass / 0 fail) were produced by assertions-harness.mjs re-running every cell/mutant live and parsing the junit XML each run wrote (per-cell junit copies and console logs in this directory), plus the two M3-fixture measurements run afterwards. Captures: 01-ab-matrix-2x2-summary.png (matrix rendered from the junit artifacts), 02-mutation-matrix-live-runs.png (mutation matrix as printed), 03-base-code-fails-new-budget-test.png (live base-arm control cell).
Flakiness gate log
rounds=5 files=1 skipped=0
file packages/core/src/core/client-goal.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/client-goal.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/core/src/core/client-goal.test.ts: PPPPP
verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/core/client-goal.test.ts: P (exit 0)
round 2 · packages/core/src/core/client-goal.test.ts: P (exit 0)
round 3 · packages/core/src/core/client-goal.test.ts: P (exit 0)
round 4 · packages/core/src/core/client-goal.test.ts: P (exit 0)
round 5 · packages/core/src/core/client-goal.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Address-review summary — PR #10126 (round)Feedback source: one issue-level comment (the sandboxed verification report, verdict Feedback points and decisionsF1 (Suggestion) — steer-input exclusion not independently pinned → IMPLEMENTED. F2 (Suggestion) — legacy hook Goal undecremented budget not pinned → IMPLEMENTED (small-budget variant). F3 (nit) — Reviewer Test Plan numbers (824 tests; 32 tsc errors) do not reproduce → DECLINED. Changes
Conflict notesNone — VerificationCommands actually run, in order:
Note for the deterministic gate: both new tests pin pre-existing retained behavior (coverage additions), so they are expected to pass against the pre-round branch as well; their discriminating power is proven by the M3/M4 mutation probes above, matching the verifier's own measurements. 中文说明处理评审总结 — PR #10126(本轮)反馈来源:一条 issue 级评论(沙箱验证报告,结论 反馈点与决定F1(建议)— steer 输入豁免未被独立钉住 → 已实现。 F2(建议)— 旧版 hook Goal 不递减的递归预算未被钉住 → 已实现(小预算变体)。 F3(小问题)— Reviewer Test Plan 中的数字(824 个测试;32 个 tsc 错误)无法复现 → 拒绝处理。 变更内容
冲突说明无 — 验证实际执行的命令及结果(按顺序):
给确定性门禁的说明:两个新测试钉住的是既有的保留行为(纯覆盖补充),因此它们在"本轮之前"的分支上同样会通过,这是预期行为;其区分能力已由上面的 M3/M4 变异探针证明,与验证者自己的实测一致。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
|
Closing in favour of #10259 (same two commits, merged with current main). Both automatic review runs on this PR — run 32953769318 on 中文说明关闭并由 #10259 取代(同样的两个 commit,已合并当前 main)。本 PR 的两轮自动评审都跑绿且判定 Approve、零发现,但均未能发出:评审机器人在本 PR 残留了一个空的 PENDING review,使每次 |



What this PR does
Audits the places where
client.tscarves runtime-scheduled Goal turns out of the session ceilings, now that a Goal has its own autonomous spend bound (#9891), and acts on each by its own merits. One exemption goes: a Goal-type send no longer ignores the caller's recursion budget (turns) —boundedTurnsisMath.min(turns, MAX_TURNS)for every message type. Three stay, with their comments rewritten to say the real reason they stay rather than the reason that stopped being true when the token budget landed.boundedTurns = Goal ? MAX_TURNS : min(turns, MAX_TURNS)MAX_TURNS= 100 nested steer / next-speaker / hook continuations)MAX_TURNSon its own; no production caller passesturnsfor a Goal send (TUI and headless both use the default; ACP does not go throughclient.sendMessageStreamat all). The carve-out only ever mattered for a caller handing over an exhausted budget, and for that caller a Goal should be refused like everything else — which it now is, and the interrupted-exit path pauses the Goal instead of leaving it running with a permit nobody will finish.if (messageType !== Retry && !isGoalRuntimeTurn)—sessionTurnCount++and themax_session_turnscheckmaxSessionTurns(user-set, default unlimited)sessionTurnCountincrements on every model call the user's prompts drive, tool continuations included. Counting Goal turns would let a user cap of N kill a healthy Goal after roughly N/4 continuations, with no resume path in headless (exit 53). The Goal's spend is bounded by its own token budget, re-armed only by an explicit resume or edit; and the headless host already excludes runtime Goal turns from the same cap (enforceSessionTurnLimit(isRuntimeGoalTurn)), so counting them here would split the two ceilings.takeSteerInput:!isGoalRuntimeTurn && sessionTurnCount >= maxSessionTurnshookTurnBudget = activeGoal ? boundedTurns : boundedTurns - 1sendMessageStreamcall, so the runtime's token budget — which meters continuations the Goal runtime schedules — never sees it. Its bounds areMAX_GOAL_ITERATIONS(50) andstopHookBlockingCap(8, pauses the Goal); a 50-iteration chain with steer and next-speaker continues would exhaustMAX_TURNSbefore its own cap, so decrementing here would trade one runaway guard for a new way to kill a legitimate run.Two further goal-runtime carve-outs in this file are not ceilings and are untouched: the hook-re-entry microcompaction checkpoint and the managed auto-memory background tasks are skipped for runtime turns. Neither has anything to do with budget; they are noted here so the next reader does not mistake them for the four above.
Why it's needed
The series plan recorded these as "the
client.tsgoal-turn ceiling exemptions to remove once a budget exists" (#9891 is that budget). Read one by one they are not one thing: one is a recursion budget the Goal had no reason to ignore, two are a user-set session cap whose semantics (every model call counts) would make Goals unusable under it, and one guards a legacy path the token budget cannot reach. Removing all four on the strength of "there is a budget now" would have regressed the two user-visible ones. The value of this PR is as much the recorded reasoning as the one-line removal: every remaining exemption now carries the reason it actually stays.Reviewer Test Plan
How to verify
cd packages/core && npx vitest run src/core/client.test.ts src/core/client-goal.test.ts src/goals/— 18 files, 824 tests. New case:holds a runtime Goal turn to the caller recursion budget like any other send(a Goal send withturns = 0does not run the model, pauses the Goal through the interrupted-exit path, and finishes the permit). The existing 75-turn runtime test no longer passes0as the budget — it was only ever passing because of the carve-out this PR removes; with the default budget it still runs all 75 turns and still leavessessionTurnCountat 0, which pins the retained session-turn exclusion.messageType === SendMessageType.Goal ? MAX_TURNS : …fails exactly the new test (22 others green); restored, 23/23.npx tsc --noEmitinpackages/core: 32 errors with and without the diff (pre-existing dependency skew outside this change, verified by exporting the diff, checking out the pristine files, and re-applying — nogit stash). prettier + eslint clean on both files.Evidence (Before & After)
N/A (no user-visible change for any production caller; the only behavioral change is for a caller passing an exhausted
turnsbudget to a Goal send, which none does).Tested on
Environment (optional)
N/A (unit tests only).
Risk & Scope
maxSessionTurnsexclusion means a user who sets a session-turn cap still sees Goals run past it. That is deliberate and now documented at the site: the Goal is bounded by its own token budget, and the headless host applies the same exclusion, so the alternative is two ceilings that disagree. If the project later wants Goals under the session cap, the right shape is a per-spend-window count reset on resume, not this exclusion's removal.enforceSessionTurnLimitexclusion (mirrors the retained one; untouched). The attribution-snapshot and auto-memory carve-outs for runtime turns (not ceilings; noted above). ACP, which drivesGeminiChatdirectly and never passes through these checks.Linked Issues
中文说明
这个 PR 做了什么
审计
client.ts中把运行时调度的 Goal 轮次排除在会话上限之外的各处豁免——现在 Goal 已经有了自己的自主消费边界(#9891)——并逐一按各自的理由处理。撤掉一处:Goal 类型的发送不再忽略调用方给的递归预算(turns),boundedTurns对所有消息类型统一为Math.min(turns, MAX_TURNS)。保留三处,并重写注释,写明它们真正保留的理由,而不是那个在 token 预算落地后已不再成立的理由。boundedTurns = Goal ? MAX_TURNS : min(turns, MAX_TURNS)MAX_TURNS= 100 层嵌套的 steer / next-speaker / hook 续跑)MAX_TURNS起步;生产中没有任何调用方给 Goal 发送传turns(TUI 和 headless 都用默认值;ACP 根本不经过client.sendMessageStream)。这个豁免只对「调用方递交一个已耗尽预算」的情形有意义,而这种情形下 Goal 应该和其他类型一样被拒绝——现在正是如此,并且中断退出路径会暂停 Goal,而不是让它带着一个无人完成的 permit 继续「运行」。if (messageType !== Retry && !isGoalRuntimeTurn)——sessionTurnCount++与max_session_turns检查maxSessionTurns(用户设定,默认不限)sessionTurnCount对用户提示驱动的每一次模型调用递增,含工具续跑。若把 Goal 轮次计入,用户设的上限 N 会让一个健康的 Goal 在大约 N/4 次续跑后中途死亡,而 headless 中没有恢复路径(退出码 53)。Goal 的消费由它自己的 token 预算约束,且只能由显式的 resume 或 edit 重新武装;headless host 已经把运行时 Goal 轮次排除在同一上限之外(enforceSessionTurnLimit(isRuntimeGoalTurn)),在这里计入会让两处上限分裂。takeSteerInput:!isGoalRuntimeTurn && sessionTurnCount >= maxSessionTurnshookTurnBudget = activeGoal ? boundedTurns : boundedTurns - 1sendMessageStream调用内递归,而 runtime 的 token 预算只计量 Goal runtime 调度的续跑,永远看不到它。它的边界是MAX_GOAL_ITERATIONS(50)和stopHookBlockingCap(8,会暂停 Goal);一条 50 次迭代、带 steer 与 next-speaker 续跑的链会在触及自己的上限之前先耗尽MAX_TURNS,所以在这里递减等于用一种新的杀死合法运行的方式换掉一个失控防护。本文件中还有两处针对 goal-runtime 的豁免不是上限,未改动:hook 重入时的 microcompaction 检查点,以及托管自动记忆的后台任务,对运行时轮次都会跳过。二者与预算无关;在此注明,以免下一位读者把它们误认为上述四处之一。
为什么需要
系列计划把这些记录为「预算落地后要撤掉的
client.tsgoal 轮次上限豁免」(#9891 就是那个预算)。逐一读过之后它们并不是同一件事:一处是 Goal 没有理由忽略的递归预算,两处是一个用户设定的会话上限、其语义(每次模型调用都计数)会让 Goal 在其下无法使用,还有一处守护着 token 预算触及不到的旧路径。仅凭「现在有预算了」就全部撤掉,会让其中两处用户可见的行为倒退。本 PR 的价值既在于那一行删除,更在于记录下来的推理:每一处保留的豁免现在都写明了它真正保留的原因。评审验证计划
如何验证
cd packages/core && npx vitest run src/core/client.test.ts src/core/client-goal.test.ts src/goals/——18 个文件,824 个测试。新增用例:holds a runtime Goal turn to the caller recursion budget like any other send(turns = 0的 Goal 发送不会调用模型,通过中断退出路径暂停 Goal,并完成 permit)。原有的 75 轮运行时测试不再传0作为预算——它此前之所以能过,只是因为本 PR 撤掉的这个豁免;改用默认预算后它仍跑完全部 75 轮,sessionTurnCount仍为 0,这固定住了保留下来的会话轮次排除。messageType === SendMessageType.Goal ? MAX_TURNS : …,恰好挂新测试(其余 22 绿);恢复后 23/23。packages/core的npx tsc --noEmit:有无 diff 均为 32 个错误(本变更之外的已有依赖偏差,通过导出 diff、检出原始文件、再重新应用来验证——未使用git stash)。两个文件 prettier + eslint 干净。证据(前后对比)
N/A(对任何生产调用方都没有用户可见的变化;唯一的行为变化针对给 Goal 发送传入已耗尽
turns预算的调用方,而现实中没有这样的调用方)。已测试平台
Linux ✅;macOS / Windows⚠️ (CI 覆盖)。
环境(可选)
N/A(仅单元测试)。
风险与范围
maxSessionTurns排除意味着设置了会话轮次上限的用户仍会看到 Goal 超出该上限运行。这是刻意的并已在现场注明:Goal 由自己的 token 预算约束,headless host 也采用同样的排除,否则就是两个互相矛盾的上限。如果项目日后希望 Goal 受会话上限约束,正确的形态是按消费窗口计数并在 resume 时重置,而不是撤掉这个排除。enforceSessionTurnLimit排除(与保留的那处一致;未改动)。运行时轮次的 attribution snapshot 与自动记忆豁免(不是上限;上文已注明)。ACP 直接驱动GeminiChat,从不经过这些检查。关联 Issue