refactor(core): stop routing Goal turns through the legacy Stop hook - #11458
Conversation
Goal v3 (`goal-runtime`/`goal-reducer`/`goal-protocol`) owns every Goal surface. The first-generation Stop-hook implementation is unreachable from core: `registerGoalHook`'s only production caller is `restoreGoalFromHistory`, which has no callers of its own. Cut core's use of the legacy path so those modules become unreferenced: - Re-home `interface ActiveGoal` from `goals/activeGoalStore.ts` into `goals/goal-legacy-projection.ts`, next to (but deliberately separate from) `LegacyActiveGoal` — the two differ in optionality, so merging them would change a wire type. `goals/index.ts` keeps exporting the name. - `core/client.ts`: drop the permit-less `getActiveGoal` fallback and the `maybeEmitActiveGoalChange` duplicate-suppression helper it fed, the `abortGoalForStopHookCap` call on the blocking-cap path, and the `goalContinuationChanged`/`discardGoalContinuation` continuation rewrite. `projectActiveGoal`/`sameActiveGoalProjection` and the v3 snapshot projection that backs the headless `active_goal` stream event are untouched. - `config/config.ts`: drop the `GOAL_HOOK_ID_OUTPUT_KEY` special-casing of Stop hook outputs, along with the `hasNonGoalBlockingStopHook` and `nonGoalBlockingStopReason` response fields that only the legacy continuation rewrite read. The legacy files themselves stay for a follow-up.
|
Thanks for the PR! Template looks good ✓ — every heading filled in, and the Risk & Scope section is unusually honest about what was not validated. Problem: this is dead-code removal rather than a bug fix, so the thing to verify is whether the code really is dead — and it is. I traced the chain independently rather than taking the description's word for it: Direction: clearly aligned. #10795 is an open roadmap issue titled "Retire the legacy Goal Stop-hook path now that Goal v3 owns every surface", and this is the first step of exactly that. #4228 is open too. No CHANGELOG signal needed — the tracking issue is the stronger one. Size: core paths only ( Approach: the scoping is the best part of this. Stopping at "core stops calling it" instead of "delete it" is right, because the CLI still holds references — and keeping
Risk: no high-risk-path match on the revert-history signal. One process note: this is a cross-repository PR with a Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 每个小节都填了,Risk & Scope 部分对"哪些没有验证"写得相当坦诚。 问题: 这是删除死代码而不是修 bug,所以要核实的是"这些代码是否真的是死的"——确实是。我没有照抄 PR 描述,而是独立追了整条链: 方向: 明确对齐。#10795 是一个开放中的 roadmap issue,标题就是"在 Goal v3 接管所有面之后退役 legacy Goal Stop-hook 路径",本 PR 正是这件事的第一步。#4228 也是开放的。不需要 CHANGELOG 信号——tracking issue 是更强的依据。 规模: 仅触及核心路径( 方案: 范围切分是这个 PR 最好的地方。停在"core 不再调用它"而不是"删掉它"是对的,因为 CLI 还持有引用;把
风险: 未命中 revert 历史的高风险路径信号。一点流程说明:这是一个跨仓库(fork)且标题为 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI tried to break the central claim — that every removed branch was unreachable — and could not. The whole diff hangs on two conditions, and both are provably never true in production:
Walking each removal against those:
The type move is handled carefully. I also confirmed the surviving emitter is the right one: Tests. Twelve removals — nine in Live non-Goal Stop-hook coverage survives and actually pins the changed lines — No blockers. Three non-blocking notes:
Cutting the obsolete recursion-hazard comment block rather than preserving it is the correct read of our comments convention — it described a mechanism this PR removes, so keeping it would mislead. Files changed (12)
TestingThis is an unattended CI run, so I did not build or execute anything from this PR — the evidence below is the PR's own CI on the reviewed commit, read through the API, plus the static equivalence proof above. Zero failures across all 71 check-runs on Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Not verified: the Linux unit suite result (still running at fetch time), and nothing here was executed locally by me. The author's own paste of 1089 + 1338 passing tests and a clean No sandboxed lane is named here because there is no behavioural claim to settle: this is a pure refactor whose entire assertion is that observable behaviour does not change, and an A/B run against a store that is never populated would show no difference trivially, without distinguishing "provably dead" from "my scenario never hit it". The equivalence argument above is strictly stronger evidence than a lane would be. 中文说明代码审查我试着去推翻它的核心论断——"每一条被删掉的分支都不可达"——没能推翻。整个 diff 依赖两个条件,而这两个条件在生产环境中可证明地永不成立:
对照这两个条件逐条走一遍删除:
类型搬迁处理得很细致。 我也确认了存活下来的那个发射器是对的: 测试。 十二处删除—— 存活的非 Goal Stop-hook 覆盖确实钉住了被改动的代码—— 无阻塞项。 三点非阻塞提醒:
把已过期的递归风险注释块删掉而不是保留,是对我们注释约定的正确理解——它描述的机制正是本 PR 移除的,留着会误导。 测试这是一次无人值守的 CI 运行,所以我没有构建或执行本 PR 的任何代码——下面的证据是 PR 自己在被审 commit 上的 CI(通过 API 读取),加上上面的静态等价性证明。
未验证:Linux 单元测试结果(拉取时仍在跑),并且我没有在本地执行任何东西。作者自己贴出的 1089 + 1338 个测试通过与 这里没有点名沙箱验证通道,因为没有行为性论断需要落定:这是一个纯粹的重构,它全部的断言就是可观测行为不变;而对着一个从不被写入的 store 做 A/B,只会平凡地显示"没有差异",无法区分"可证明是死的"和"我的场景没走到"。上面的等价性论证是比任何通道都更强的证据。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 3/5 — clean review; the cap is pure policy (cross-repository PR with a Stepping back: this is 892 net deletions and the only additive code is a sixteen-line interface relocation plus a comment rewrite. I went in expecting to argue about scope and there wasn't an argument to have — every line in the diff is load-bearing for the stated goal, there's no drive-by formatting, and the one comment block that gets cut is cut because it described the mechanism being removed. Deleting it is the right read of our convention, not a violation of it. Against my own baseline proposal the PR came out ahead. I'd have sketched the same shape — prove the legacy store is never populated, delete the branches that guard on it, leave the modules compiling for the CLI — but I'd probably have left The thing I'd want a human to weigh is not correctness but timing. The equivalence argument is airtight today and it is not permanent: it holds because On the honesty scale: this buys maintainability, not user-visible anything. The If I picked this up in six months I'd thank them. The Stop-hook block in Verification of the problem. For a dead-code PR the problem is the deadness, and I didn't take it on trust — three independent routes agree: the call graph bottoms out at a function with no callers; Pattern. The author has thirteen PRs open, so I checked whether I was being worn down by volume. I don't think so: there's a consistent merge record behind them, I evaluated this one on its own diff, and the sibling PR existing is evidence of a plan rather than of churn. ⏸️ Deferring — not approving. Two reasons, and the second is procedural rather than a judgement on the work:
I could not resolve an owner to hand this to, so there's no @mention here rather than a guessed one: the PR currently carries no labels, and the area map routes on labels, so nothing matched. Worth naming the awkward part — 中文说明信心度:3/5 —— 审查本身是干净的;这个上限纯粹来自策略(跨仓库 PR 且标题为 退一步看整体:这是净删除 892 行,唯一新增的代码是一个十六行的接口搬迁加一段注释重写。我本来准备好要就范围争论一番,结果没什么可争的——diff 里每一行都为既定目标服务,没有顺手改格式,而唯一被删掉的那段注释之所以被删,正是因为它描述的机制被移除了。删掉它是对我们注释约定的正确理解,不是违反。 对照我自己的基线方案,这个 PR 做得更好。我会给出同样的形状——证明 legacy store 从不被写入、删掉以它为条件的分支、为 CLI 保留模块可编译——但我大概会把 我最希望人来权衡的不是正确性,而是时机。等价性论证今天是严密的,但它不是永久的:它成立是因为 坦白地说:这买到的是可维护性,不是任何用户可见的东西。 如果六个月后由我接手这块代码,我会感谢作者。 问题核实。 对删除死代码的 PR 来说,"问题"就是"它是死的"这件事,我没有照单接受——三条独立路径结论一致:调用图最终落在一个没有调用者的函数上; 模式。 作者有十三个开放 PR,所以我检查了自己是不是被数量磨软了。我认为没有:背后有稳定的合并记录,我是就这一个 diff 单独评估的,而姊妹 PR 的存在是"有计划"的证据,不是"刷量"的证据。 ⏸️ 暂缓——不批准。 两个原因,第二个是流程性的,不是对这份工作的评价:
我没能解析出应该交给谁,所以这里没有 @提及,而不是猜一个:该 PR 目前没有标签,而 area 映射是按标签路由的,因此没有任何 area 命中。有一点值得点明其中的尴尬之处—— — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- activeGoalEquals orphaned export (packages/core/src/goals/activeGoalStore.ts:16) — already reported (comment 5598940816; repeated as note 2 of comment 5598995990)
Test Plan (not a blocker): Tests 1089 passed — this review observed 23949, 1983, 29459, 298, 1830, 515, 6854 passed; Tests 1338 passed — this review observed 23949, 1983, 29459, 298, 1830, 515, 6854 passed.
中文说明
已审查——无阻断问题。 建议见行内评论。
本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
Test Plan(非阻断):Tests 1089 passed — this review observed 23949, 1983, 29459, 298, 1830, 515, 6854 passed; Tests 1338 passed — this review observed 23949, 1983, 29459, 298, 1830, 515, 6854 passed。
— qwen3.8-max via Qwen Code /review (v0.23.1)
…top bridge tested Two review findings from the deletion pass. `client.ts` had inlined `stopReason || reason || 'No reason provided'`, which is `DefaultHookOutput.getEffectiveReason()` verbatim -- the accessor the sibling branch of the same `if`/`else` already calls on the same object. Dropping the `goalHook.js` import only required dropping `getStopHookContinuationReason`, not the accessor. Calling it again puts this branch back under the coverage the rule already has (`hookAggregator.test.ts` pins the `stopReason` precedence), instead of leaving a private copy in the hottest function in core for the next change to that rule to miss. Deleting the goal assertions from `Stop dispatch through the hook execution bridge` also took the harness around them, which was the only thing in the repository that drove `eventName: 'Stop'` through Config's real message-bus handler -- while the sibling `UserPromptSubmit` and `MessageDisplay` bridge suites were kept. Restore a goal-free pair beside them: one asserting the branch forwards `stop_hook_active`, `last_assistant_message`, the computed context usage and the signal positionally, wraps the final output, and reports `allOutputs.length`; one asserting a Stop that produced no output wraps nothing and counts zero. `stopHookCount` is read off the bridge response rather than through a consumer, because the field is optional and both consumers mask a missing value with `?? 1`. The wrap is asserted on the `createHookOutput` call, since this file replaces the hooks module with a bare mock. Verified as an acceptance criterion: making `stopHookCount` a constant, dropping the wrap, swapping the two positional inputs, and emptying the branch each turn the suite red.
|
All three taken, in 6296d36. I checked each one against the tree rather than taking it on trust, and all three held up. R1-3 — the inlined fallback chain. Confirmed and fixed. R1-1 — the Stop bridge harness. Confirmed, including the scope of "only". The remaining Restored a goal-free pair beside the two surviving bridge suites. Two deviations from your sketch, both forced by the file:
I treated your "the restored case is its own acceptance criterion" as a requirement rather than a remark, and mutated the branch four ways to check it:
R1-2 — the design doc. Confirmed, and recording it here as a deferral rather than editing in place, which is the option you flagged as cheaper. The reason it is cheaper than you knew: Deferred to the follow-up PRs in this sequence, so nothing is silently dropped:
|
yiliang114
left a comment
There was a problem hiding this comment.
Approving. Clean removal of the legacy Goal Stop-hook routing: client.ts drops the activeGoalAtTurnStart read-through and the goalHook/activeGoalStore imports, goalHook.ts drops the stop-hook-cap exports, and the new ActiveGoal interface in goal-legacy-projection.ts is deliberately kept separate from LegacyActiveGoal so the wire type doesn't silently change. Net −789 lines, tests green (1089/1338 passed), CI fully green. Bot review found no blockers — the one Suggestion (orphaned activeGoalEquals export) is non-blocking.
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings.
Approval blockers: none.
Scope: packages/core/src source and test changes only. NOT reviewed: CLI callers of the legacy modules (out of scope per PR description); Windows/macOS runtime behavior (no host).
What I checked
Class 1 — contract asymmetry (getStopHookContinuationReason → getEffectiveReason).
Old function had two branches: no-Goal output → stopReason || reason; Goal output → [stopReason, reason].join('\n'). New DefaultHookOutput.getEffectiveReason() always does stopReason || reason. The Goal-specific join branch was only reachable when the legacy store held an active goal, which is never the case in production (sole writer restoreGoalFromHistory has no callers). For every live Stop hook, old and new produce identical output. Clean.
Class 2 — API/export surface. ActiveGoal interface: moved from activeGoalStore.ts to goal-legacy-projection.ts, re-exported from goals/index.ts at the same name (confirmed at HEAD line 59). Callers importing from the barrel are unaffected. HookExecutionResponse field removals: no write site or read site outside the deleted code at HEAD. Clean.
Class 7 — zombie state / hookTurnBudget change. Old: activeGoal ? boundedTurns : boundedTurns - 1. New: unconditionally boundedTurns - 1. Since getActiveGoal(sessionId) returns undefined whenever the legacy store is empty — which is always in production — this always resolved to boundedTurns - 1. Simplification is correct. Clean.
continueRequest simplification. For the only live path (no legacy Goal in flight), old code set continuationReasonAfterSteer = continueReason unchanged and built [{ text: continueReason }]. New code does exactly the same. The early-return path (settle goal proposal and stop when both reason and steer are absent) was only entered when a legacy goal changed mid-continuation — dead in production. Clean.
Config test suite (surviving case 'Stop': branch). The old parametric it.each describe was replaced, not deleted: two new focused tests exercise the surviving bridge branch with positional assertions and a count check. Adequate.
Cross-check against existing reviews
Three prior bot suggestions; author reports all three addressed in 6296d36 (current HEAD):
| Bot finding | My check | Outcome |
|---|---|---|
| Config test: surviving Stop branch coverage lost | New describe has 2 tests covering case 'Stop': with positional + count assertions |
Confirmed resolved |
types.ts: removed fields still named in design doc |
Nothing reads or writes the fields at HEAD; no wire format changes | Confirmed resolved |
client.ts: inlined fallback is a copy of getEffectiveReason() |
HEAD calls stopOutput.getEffectiveReason() directly |
Confirmed resolved |
yiliang114 approval — independently reached the same conclusion (clean deletion of dead legacy Goal routing).
Unreviewed dimensions: CLI callers of registerGoalHook/restoreGoalFromHistory not reviewed (author scopes to packages/core; CLI-side deletion is the stated next PR). No execution rung run — working tree unavailable; not material to approval given the code paths are verifiably dead (legacy store never written in production).
Reviewed with AI assistance.
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Approved at head 6296d362.
Required CI is green here — Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke and both Desktop Shell lanes completed successfully; only review-pr (the reviewer's own job) is still running.
I did not take the "it was already dead" premise on trust, because the whole diff stands on it:
registerGoalHookhas exactly one non-test caller (cli/src/ui/utils/restoreGoal.ts:369), and it lives insiderestoreGoalFromHistory(:324), which has no non-test caller anywhere. Nothing else outsidecore/src/goals/writes the store, so every removedgetActiveGoal(...)arm evaluated toundefinedin a real session. The one remaining non-test consumer of the legacy API,abortGoalForStopHookCapatcli/src/acp-integration/session/Session.ts:6606, still resolves — this PR only stops core from calling it.- Dropping
getStopHookContinuationReasonforstopOutput.getEffectiveReason()preserves the produced string on every reachable input: the two differ only for an output carryingqwenGoalHookId, and only the goal hook ever set that key.createHookOutputreturnsDefaultHookOutput(hooks/types.ts:359-387), which declares the accessor and is not overridden byStopHookOutput, so this also matches the sibling goal-permit branch that already called it at:4387. - The
ActiveGoalmove is complete:turn.ts,goalHook.tsandactiveGoalStore.test.tswere repointed,goals/index.tsstill exports the same name, and no module imports it fromactiveGoalStore.jsany more. boundedTurns - 1is the arm every non-legacy turn already took, andLlmEventType.ActiveGoalis still produced by the surviving v3 projection (client.ts:2921-2940, throughprojectActiveGoal/sameActiveGoalProjection) — whichclient-goal.test.ts:1091,:1629and:1679still assert, so the duplicate-suppression contract did not lose its only observer.
Test coverage is preserved rather than narrowed: the Stop dispatch through the hook execution bridge describe at config.test.ts:13093 was rewritten goal-free in place instead of being dropped with the two response fields, and it still drives eventName: 'Stop' through the real message bus — asserting the positional fireStopEvent forwarding and reading stopHookCount (2 / 0) off the bridge response itself rather than through the ?? 1 consumers. So the surviving case 'Stop': branch keeps the only observer that can go red if it stops forwarding stop_hook_active / last_assistant_message, loses the createHookOutput('Stop', ...) wrap, or hard-codes the count.
No new Critical found. One non-blocking follow-up, which the earlier inline suggestion asked for and this head does not carry: docs/design/goal-loop-input-control.md:32-37 and its Verification bullet at :45-46 still specify the goal-vs-non-goal Stop separation and the "aggregated independent blocker" tests that this change retires. Worth a status note there, or an explicit deferral owned by the follow-up deletion PR, so the next reader doesn't go hunting for code that no longer exists.
|
Released in v0.23.3. |
What this PR does
Core no longer routes any Goal through the first-generation Stop-hook implementation. The
LlmClientturn loop drops the fallback that read a Goal out of the in-memory legacy store whenever a turn arrived without a v3 permit, along with everything that fallback fed: the duplicate-suppressingactive_goalemitter, the blocking-cap abort that reached into the store, the recursion-budget exception a legacy Goal used to get, and the Stop-hook continuation rewrite that compared hook ids to decide whether a Goal had been cleared mid-continuation. The hook execution bridge drops the two response fields that existed only so that rewrite could tell a Goal's blocking Stop output from anyone else's.The
ActiveGoalinterface moves out of the legacy store module intogoal-legacy-projection.ts, because it is still the shape of a live compatibility surface: the headlessactive_goalstream event and theServerLlmActiveGoalEventthat carries it. It sits beside the existingLegacyActiveGoalrather than merging with it — the two differ in which fields are required, so merging would change a wire type — and the core barrel keeps exporting the same name.The legacy modules themselves (
goalHook.ts,goalJudge.ts,activeGoalStore.ts) are untouched and still compile. Two CLI call sites keep them referenced, so deleting them needs a CLI-side change first; that is the next PR in this sequence.Why it's needed
The two generations have coexisted for a while, and the duplication is the main thing that makes the Goal code hard to read: a reader cannot tell which judge is authoritative without tracing call graphs. It is also not a live/live split.
registerGoalHook's only production caller isrestoreGoalFromHistory, and that function has no callers of its own, so the legacy store is never populated in a real session and the legacy judge never runs. What remained was cost: dead branches in the hottest function in core, two response fields nothing else could use, and a comment block explaining a recursion hazard that can no longer occur.Removing core's use of the dead generation is also the prerequisite for deleting it. The files stay referenced from the CLI until the sibling PR lands, so this one is deliberately scoped to "stop calling it" rather than "delete it".
Reviewer Test Plan
How to verify
The claim worth checking rather than taking on trust is that nothing live was cut. Three things had to survive, and all three are still there:
projectActiveGoalandsameActiveGoalProjection, thetakePendingGoalEventsprojection that turns a v3 snapshot into theactive_goalevent, and theActiveGoaltype itself under its original exported name. The removed emitter was a second, separate suppressor that only ever saw store-sourced Goals; the v3 one is untouched.Also worth confirming: with the legacy branch gone the Stop-hook recursion budget is unconditionally
boundedTurns - 1, which is the arm every non-legacy turn already took.Evidence (Before & After)
Non–user-visible refactor: N/A for screenshots. The observable evidence is that the legacy path is unreachable from core and the suites are unchanged in intent:
Tested on
Environment (optional)
Linux, package-local vitest and
tsc --noEmitforpackages/core.Risk & Scope
LlmClient's Stop-hook handling, which is dense and shared with non-Goal hooks. The mitigation is that every removed branch was guarded by a legacy-store lookup that can only returnundefinedin a real session, and the tests that covered those branches were removed rather than weakened — nine inclient.test.ts, two inclient-goal.test.ts, and onedescribeinconfig.test.ts, each because the mechanism it asserted no longer exists. One test was rewritten instead of deleted (the abort-before-continuation case), because its surviving assertion is about Stop hooks generally rather than about Goals.projectLegacyActiveGoal, theactive_goalstream event,_meta.goalStatus,BridgeSessionGoal.activeand the pre-v3goal_statustranscript migration are all deliberately untouched.HookExecutionResponseloses two optional fields,hasNonGoalBlockingStopHookandnonGoalBlockingStopReason. Nothing in the repository writes them except the code removed here, and nothing reads them at all. No wire format, persisted record, or user-visible behaviour changes.Linked Issues
Part of #10795. Part of #4228.
中文说明
这个 PR 做了什么
core 不再让任何 Goal 走第一代 Stop-hook 实现。
LlmClient的轮次循环去掉了那条兜底逻辑——每当一个轮次没有带 v3 permit 时,就从内存里的 legacy store 读出一个 Goal——以及这条兜底所喂养的一切:做重复抑制的active_goal发射器、伸手进 store 的阻塞上限中止逻辑、legacy Goal 曾经享有的递归预算例外,以及那段通过比对 hook id 来判断 Goal 是否在续跑中途被清除的 Stop-hook 续跑重写。hook 执行桥同时去掉了两个响应字段——它们存在的唯一目的就是让那段重写能把 Goal 的阻塞 Stop 输出与别人的区分开。ActiveGoal接口从 legacy store 模块搬到了goal-legacy-projection.ts,因为它仍然是一个存活的兼容面的形状:headless 的active_goal流事件,以及承载它的ServerLlmActiveGoalEvent。它与已有的LegacyActiveGoal并列而不是合并——两者在哪些字段必填上不同,合并会改变一个线协议类型——而 core 的 barrel 继续导出同一个名字。legacy 模块本身(
goalHook.ts、goalJudge.ts、activeGoalStore.ts)未被触及,仍然参与编译。有两处 CLI 调用点让它们保持被引用,所以删除它们需要先做一次 CLI 侧改动;那是本序列的下一个 PR。为什么需要
两代实现已经共存了一段时间,而这份重复正是让 Goal 代码难读的主要原因:读者不追调用图就分不清哪个判定者才是权威。它也不是"两个都活着"的分裂。
registerGoalHook在生产代码里唯一的调用者是restoreGoalFromHistory,而后者自己没有任何调用者,所以 legacy store 在真实会话里从不被写入,legacy judge 也从不运行。剩下的只有代价:core 里最热的那个函数中的死分支、两个别处无法使用的响应字段,以及一段解释一种已经不可能发生的递归风险的注释。移除 core 对这代死代码的使用,也是删除它的前提。在姊妹 PR 落地之前,这些文件仍被 CLI 引用,所以本 PR 刻意把范围收在"不再调用它"而不是"删除它"。
评审验证方式
如何验证
值得亲自核对而不是照单接受的论断是:没有任何存活的东西被砍掉。有三样必须存活,而它们都还在:
projectActiveGoal与sameActiveGoalProjection;把 v3 快照转成active_goal事件的takePendingGoalEvents投影;以及ActiveGoal类型本身,仍以原来导出的名字存在。被删掉的那个发射器是第二个、独立的抑制器,它只见过来自 store 的 Goal;v3 那个未被触及。同样值得确认:legacy 分支移除后,Stop-hook 的递归预算无条件为
boundedTurns - 1,而这正是每一个非 legacy 轮次本来就走的那一支。证据(前后对比)
不面向用户的重构:截图部分为 N/A。可观察的证据是 legacy 路径从 core 已不可达,且测试套的意图未变:
测试环境
运行环境(可选)
Linux,
packages/core的包内 vitest 与tsc --noEmit。风险与范围
LlmClient的 Stop-hook 处理里做删除,那段代码很密,而且与非 Goal 的 hook 共用。缓解在于:每一个被删掉的分支都由一次 legacy store 查询把门,而这个查询在真实会话里只可能返回undefined;覆盖那些分支的测试是被删除而不是被弱化——client.test.ts里九个、client-goal.test.ts里两个、config.test.ts里一个describe,各自都是因为它断言的机制已不存在。只有一个测试是被改写而不是删除(中止先于续跑那个用例),因为它仍然成立的那条断言讲的是 Stop hook 的通用行为,而不是 Goal。projectLegacyActiveGoal、active_goal流事件、_meta.goalStatus、BridgeSessionGoal.active与 pre-v3 的goal_statustranscript 迁移都刻意未动。HookExecutionResponse少了两个可选字段,hasNonGoalBlockingStopHook与nonGoalBlockingStopReason。仓库里除了本次被删掉的代码之外没有任何地方写入它们,也没有任何地方读取它们。线协议格式、持久化记录与用户可见行为均未改变。关联 Issue
Part of #10795. Part of #4228.