Skip to content

test(core): close the deferred test gaps recorded in #9930's review rounds - #10465

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
tomsen02:test/debug-log-routing-deferred-gaps
Sep 1, 2026
Merged

test(core): close the deferred test gaps recorded in #9930's review rounds#10465
wenshao merged 4 commits into
QwenLM:mainfrom
tomsen02:test/debug-log-routing-deferred-gaps

Conversation

@tomsen02

Copy link
Copy Markdown
Contributor

What this PR does

Closes the three test gaps that #9930's review rounds 3–4 recorded as non-blocking deferrals, submitted by the same author as a follow-through. Two gaps become mutation-killing tests; the third — an sessionIdContext.exit wrapper the review probe showed to be mutation-unreachable — is removed as dead defensive code rather than left permanently unpinnable.

Why it's needed

Deferred review findings rot unless someone owns them. All three were recorded against code this author shipped in #9930, so closing them here keeps the debug-log-routing work fully pinned:

  • Rotation fallback branch (round-4: "deletion mutant survives all 579 tests"): the fallback holds a live Config reference, so rotating the same Config reroutes writes even without the rotation-time claim — the claim is only observable when another Config (transcript replay, bootstrap) took the fallback in between and the un-contexted rotation must re-claim it. The new test drives exactly that interloper scenario.
  • Streak-cap recovery (round-3: "circuit-breaker mutant survives all 40 tests"): the cap must act as a circuit breaker, not a latch — a capped streak still attempts on a session change, and one success re-opens retries for later transient failures.
  • Drain-side sessionIdContext.exit (round-4: "mutation-unreachable"): every path into the drain — the start-side kick, the .finally re-kick, and the retry timers — already runs context-free behind the start-side exit in startNonInteractiveOpenAILogHousekeeping (timers registered inside that scope inherit it). A second wrapper was unreachable defensive code no test could pin, so it is removed; the start-side exit remains the single tested choke point. The inner return becomes continue, which is behaviorally identical (finally still runs, then the while condition ends the loop).

Reviewer Test Plan

How to verify

  1. cd packages/core && npx vitest run src/config/config.test.ts src/utils/debugLogger.test.ts → 621/621.
  2. cd packages/cli && npx vitest run src/services/housekeeping/non-interactive-scheduler.test.ts → 14/14 (unchanged tests still pass against the simplified drain).
  3. Mutation checks (both re-run locally at this head):
    • delete the rotation-path setDebugLogSession(this) guard block in config.tsclaims the global debug fallback on un-contexted rotation fails;
    • insert if (aliasFailureStreak >= MAX_CONSECUTIVE_ALIAS_FAILURES) return; before alias scheduling in debugLogger.tsrecovers from the streak cap when a later alias update succeeds fails.

Evidence (Before & After)

Before: both mutants survive the full suites (as recorded in #9930's round-3/round-4 review ledgers). After: each mutant fails its named test; real code passes 621/621 + 14/14. No user-visible TUI change — N/A for screenshots.

Tested on

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

Environment (optional)

vitest from each package dir on macOS (Darwin 25.4.0, Node 22). N/A beyond unit tests.

Risk & Scope

  • Main risk or tradeoff: the drain-side wrapper removal is the only production change; behavior is identical on every reachable path (argued above, and the existing drain-side context test still passes). If a future caller ever kicks the worker from inside a bound session context, the invariant now lives solely in the start-side exit — which is the pinned, documented choke point.
  • Not validated / out of scope: macOS/Windows CI lanes (skipped for fork PRs); no changes to the fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930 routing semantics themselves.
  • Breaking changes / migration notes: none.

Linked Issues

Relates to #9535, #9538, #9930 (closes the deferred items recorded in #9930's round-3/round-4 reviews; no standalone issue was filed since the findings live in those review ledgers).

中文说明

本 PR 做了什么:收掉 #9930 评审第 3、4 轮记录在案的三条非阻塞遗留(同一作者跟进)。两条补上可杀变异体的测试;第三条(评审探针证明"变异不可达"的 drain 侧 sessionIdContext.exit 包装)按死防御代码移除,而非留下永远无法钉住的缺口。

为什么需要:deferred 评审发现无人认领就会腐烂。三条都出自本作者在 #9930 交付的代码:(1) rotation 回退分支——全局回退持有 Config 活引用,只有"另一个 Config 中途抢走回退、无上下文 rotation 必须抢回"的场景能观察到该调用,新测试正是驱动这个闯入者场景(删除变异体现在会红);(2) streak 上限恢复——上限必须是断路器而非闩锁:触顶后换会话仍要尝试,一次成功即重开重试(永不再试的闩锁变异体现在会红);(3) drain 侧 exit——进入 drain 的所有路径(start 侧启动、.finally 重启、重试定时器)都已在 start 侧 exit 之后运行、天然无上下文,第二层包装不可达故移除;内层 return 改为行为等价的 continue

验证:core 621/621、cli scheduler 14/14;两个变异体均被点名测试杀死;typecheck/ESLint/Prettier 干净。macOS 本地已测,Windows/Linux 交 CI。

风险与范围:唯一生产改动是移除不可达包装,所有可达路径行为不变(现有 drain 侧上下文测试仍通过);不变更 #9930 的路由语义本身。无破坏性变更。

关联#9535#9538#9930(收掉其 round-3/4 评审台账中的 deferred 项;发现记录在评审台账中,故未另开 issue)。

🤖 Generated with Claude Code

…view rounds

Three items QwenLM#9930's rounds 3-4 recorded as non-blocking deferrals, now
resolved by the same author:

- Rotation fallback branch (config.test.ts): pinned via the re-claim
  scenario — the fallback holds a live Config reference, so only an
  interloper Config taking the fallback between construction and an
  un-contexted rotation makes the rotation-time claim observable.
  Deletion mutant now fails the test.
- Streak-cap recovery (debugLogger.test.ts): the cap must act as a
  circuit breaker, not a latch — a capped streak still attempts on a
  session change, and one success re-opens retries. The
  never-attempt-again latch mutant now fails the test.
- Drain-side sessionIdContext.exit (scheduler.ts): removed rather than
  tested — every path into the drain (start-side kick, .finally re-kick,
  retry timers) already runs context-free behind the start-side exit,
  so the second wrapper was unreachable defensive code no test could
  pin. The inner `return` becomes `continue` (equivalent: finally still
  runs, the while condition exits the loop).

Verification: core config 580/580 + debugLogger 41/41, cli scheduler
14/14 + acpAgent 531/531; both mutants killed by their named tests;
typecheck/ESLint/Prettier clean.

Relates to QwenLM#9535, QwenLM#9538, QwenLM#9930.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the follow-through — re-run on the current head a6cab44 (three commits since the last pass: the R1-1 helper extraction, the TS2345 type fix, and the comment-only drain note answering the human review).

  • Template looks good ✓
  • Problem: real and recorded — all three gaps trace to fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930's review ledger: the round-4 probe deferrals (rotation-fallback deletion mutant surviving, drain-side sessionIdContext.exit being mutation-unreachable) and the round-3 "circuit-breaker mutant survives all 40 tests" entry. No standalone issue needed — the findings live in those ledgers.
  • Direction: aligned — named non-blocking deferrals on code the same author shipped in fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930, closed by that author. Nothing speculative.
  • Size: core paths touched — 92 production lines (50+/42−, all in the housekeeping scheduler) vs. 169 test lines (config.test.ts 85+/37−, debugLogger.test.ts 47+/0). Well under any advisory threshold; test-type title, so the refactor gate doesn't apply.
  • Approach: scope feels right. Two gaps become mutation-killing tests; the third item is removed because an unreachable wrapper can't be pinned by any test — removal is the honest fix. The R1-1 fix improved on the original: the shared withDebugFallbackIsolation helper spies the full fs surface the fallback path touches — including readlink, which both original tests missed — and the new drain comment records the invariant at the removal site, which is what the human reviewer asked for.
  • Risk: no elevated risk signals — no high-risk path matches; the production delta is a pure removal of dead defensive code plus an explanatory comment.

Moving on to code review. 🔍

中文说明

感谢跟进——本次在当前 head a6cab44 上重跑(距上次通过新增三个提交:R1-1 辅助函数抽取、TS2345 类型修复、以及回应人类评审的 drain 注释改动)。

  • 模板完整 ✓
  • 问题:真实且有据可查——三个缺口均可追溯到 fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930 的评审台账:round-4 的探针遗留(rotation 回退分支删除变异体存活、drain 侧 sessionIdContext.exit 变异不可达)与 round-3 的"断路器变异体在全部 40 个测试中存活"条目。无需单开 issue——发现本身就记录在评审台账中。
  • 方向:对齐——同一作者收掉自己在 fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930 中交付代码的点名非阻塞遗留项,无任何投机性内容。
  • 规模:触及核心路径——生产代码 92 行(50+/42−,全部在 housekeeping scheduler),测试 169 行(config.test.ts 85+/37−、debugLogger.test.ts 47+/0),远低于任何提示阈值;标题为 test 类型,不适用 refactor 门禁。
  • 方案:范围合理。两个缺口变成可杀变异体的测试;第三项因不可达的包装无法被任何测试钉住而移除——移除是诚实的处理。R1-1 修复比原实现更好:共享的 withDebugFallbackIsolation 辅助函数对回退路径触及的完整 fs 表面打桩——包括两个原测试都遗漏的 readlink——且新的 drain 注释在移除处记录了不变式,正是人类评审所要求的。
  • 风险:无升级风险信号——未匹配高风险路径;生产改动是纯删除死防御代码加一条解释性注释。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Code review — re-run at a6cab44; I re-traced everything against the final code, including the three commits that landed since the last pass. Nothing blocking.

My independent proposal for the three deferred items matches what landed: an interloper-Config test for the rotation re-claim, a circuit-breaker-recovery test for the streak cap, and removal of the unreachable wrapper rather than an unpinnable comment-only wrapper.

What I verified on this pass:

  • Rotation test vs. the guard it pins: the claim semantics live in two identical guards in config.ts — Config construction and startNewSession both call setDebugLogSession(this) only when sessionIdContext.getStore() === undefined. Because the fallback holds a live Config reference, rotating the same Config reroutes writes even without the rotation-time claim — so the test's interloper Config (created context-free after the CLI Config) is the only way to make the re-claim observable. Delete the startNewSession guard and the post-rotation log lands in the interloper's file: both assertions fail. The mutant is dead.
  • Streak test vs. the cap it pins: traced through debugLogger.ts. Attempts are gated only by the per-session lastAliasedKey dedup marker; the cap's real effect is that a 3rd consecutive failure stops clearing that marker (streak 1 and 2 clear it, streak 3 doesn't). A different session's key always attempts — even at cap — and one success resets the streak to 0. The test's counts check out against the code: 3 symlink calls at cap (the 4th write is deduped, not attempted), a 4th for the session change, 5th/6th after the success re-opens retries. Insert the latch mutant (return at cap before scheduling) and the session-change attempt never happens — toHaveBeenCalledTimes(4) fails. The mutant is dead.
  • R1-1 fix: the shared withDebugFallbackIsolation helper single-sources env save/restore and state reset, and spies the full fs surface the fallback/alias path touches — including readlink, which both original copies missed. The TS2345 is correctly worked around: no typed spy callback, the bodies read the spy back via vi.mocked(fs.promises.appendFile), and CI's typecheck + unit lanes are green on this head.
  • Drain wrapper removal: every entry into drainNonInteractiveQueue is structurally context-free — the start-side kick runs inside sessionIdContext.exit in startNonInteractiveOpenAILogHousekeeping, the .finally re-kick and the retry timers are registered inside that exited scope and inherit it, and both startNonInteractiveWorker and the drain are module-private with no other callers. The returncontinue change is behaviorally identical (finally runs either way; the loop condition then exits since nonInteractiveStopping is set). The sessionIdContext import stays used by the start-side exit, and the existing keeps process-scoped cleanup outside session contexts test now pins that exit as the single choke point. The new comment at the removal site names the choke point and warns future entries must go through it — this is the human reviewer's greppable-coupling ask; skipping the dev-guard invariant check is a reasonable call for a module-private drain with three known entries.

One residual, named by the PR itself: if a future caller ever kicks the worker from inside a bound session context, the invariant lives solely in the start-side exit. Acceptable — every worker entry is module-private today and the comment records the invariant — but worth knowing when extending this scheduler.

Testing — unattended CI run; no PR code was built or executed here. Evidence below is the PR's own CI on a6cab44, fetched via the API — all green. Fork-lane skips: macOS/Windows unit lanes and the sandboxed Integration Tests (CLI, No Sandbox) don't run for fork PRs. The author's mutation re-run numbers (621/621 core, 14/14 scheduler) remain the author's claim from the PR description.

Check Conclusion
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

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

Mutation testing is not part of CI, so green suites prove the tests pass but not that they pin the mutants. The sponsored @qwen-code /verify run is already in flight on this PR (live progress) and will post its A/B report separately — it settles exactly that claim: whether the two new tests kill their named mutants and the scheduler suite still pins drain behavior after the wrapper removal. My static trace above already found both tests load-bearing, so this is belt-and-braces, not a flagged gap. Nothing user-visible changes, so no TUI lane applies.

中文说明

代码审查——在 a6cab44 上重跑;对照最终代码(含上次通过后的三个新提交)重新追溯了全部内容。无阻塞问题。

我对三个遗留条目的独立提案与最终落地一致:用"闯入者" Config 测试 rotation 重新认领、用断路器恢复测试钉住 streak 上限、直接移除不可达包装而非留一个无法钉住的注释版包装。

本轮核实内容:

  • rotation 测试与其钉住的守卫:认领语义位于 config.ts 中两处相同的守卫——Config 构造与 startNewSession 都只在 sessionIdContext.getStore() === undefined 时调用 setDebugLogSession(this)。由于回退持有 Config 活引用,旋转同一个 Config 即使不做认领也会改道写入——因此测试中在 CLI Config 之后、无上下文创建的闯入者 Config 是让 re-claim 可观察的唯一方式。删除 startNewSession 守卫后,旋转后的日志会落入闯入者文件:两条断言均失败。变异体被杀死。
  • streak 测试与其钉住的上限:已对照 debugLogger.ts 追溯。尝试仅受按会话的 lastAliasedKey 去重标记门控;上限的实际效果是第 3 次连续失败后不再清除该标记(第 1、2 次会清除,第 3 次不会)。不同会话的 key 总会尝试——即使触顶——且一次成功将 streak 重置为 0。测试计数与代码吻合:触顶时 3 次 symlink 调用(第 4 次写入被去重而非尝试)、会话切换第 4 次、成功重开重试后第 5/6 次。插入闩锁变异体(调度前在上限处 return)则会话切换的尝试不会发生——toHaveBeenCalledTimes(4) 失败。变异体被杀死。
  • R1-1 修复:共享的 withDebugFallbackIsolation 辅助函数统一了环境变量保存/恢复与状态重置,并对回退/别名路径触及的完整 fs 表面打桩——包括两处原实现都遗漏的 readlink。TS2345 的规避方式正确:不使用带类型的 spy 回调,测试体通过 vi.mocked(fs.promises.appendFile) 读回 spy,且该 head 上 CI 的类型检查与单元测试通道均为绿色。
  • drain 包装移除:进入 drainNonInteractiveQueue 的每条路径在结构上都无上下文——start 侧启动运行在 startNonInteractiveOpenAILogHousekeepingsessionIdContext.exit 内,.finally 重启与重试定时器都在该已退出作用域内注册并继承之,且 startNonInteractiveWorker 与 drain 均为模块私有、无其他调用方。returncontinue 行为完全等价(两种写法 finally 都执行;随后循环条件因 nonInteractiveStopping 已置位而退出)。sessionIdContext 导入仍被 start 侧 exit 使用,既有的 keeps process-scoped cleanup outside session contexts 测试现在正好钉住该 exit 这个唯一咽喉点。移除处的新注释点名了咽喉点并警告未来入口必须经过它——这正是人类评审"让耦合可 grep"的要求;对一个只有三个已知入口的模块私有 drain,跳过 dev 守卫不变式检查是合理的取舍。

PR 自己点名的残留:若未来有调用方在绑定会话上下文内启动 worker,不变式将只靠 start 侧 exit 维系。可以接受——当前所有 worker 入口均为模块私有,注释也记录了该不变式——但后续扩展此调度器时值得留意。

测试——无人值守 CI 运行;此处未构建或执行任何 PR 代码。下方证据是 a6cab44 上 PR 自身 CI 经 API 抓取的结果——全部绿色。fork 通道跳过:macOS/Windows 单元通道与沙箱版 Integration Tests (CLI, No Sandbox) 不对 fork PR 运行。作者的变异体复跑数字(core 621/621、scheduler 14/14)仍是 PR 描述中的作者声明。变异测试不是 CI 的一部分,绿色套件只能证明测试通过、不能证明测试钉住了变异体。赞助的 @qwen-code /verify 运行已在本 PR 上进行中,将单独发布 A/B 报告——它正好收掉这个论断。上面的静态追溯已确认两个测试均承重,因此这是双保险而非标记缺口。无用户可见变化,不适用 TUI 通道。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal follow-through on three recorded deferrals; the only check this lane cannot re-run itself is the mutation testing, which the in-flight sponsored /verify run covers and my static trace already substantiates.

Reflection: this is what a good deferral follow-through looks like, and the review rounds made it better rather than bigger. The R1-1 fix didn't just extract a helper — it closed a real isolation hole (the missing readlink spy) that both original tests shared, and the TS2345 detour was resolved the right way (read the spy back via vi.mocked, no type-erasing casts). The drain removal stays the honest core of the PR: code no test could ever reach is deleted, the invariant moves into a comment at the removal site naming the single tested choke point, and the human reviewer's coupling concern is addressed without reintroducing machinery. Every line of the final diff earns its place — no drive-bys, and the three commits since the last pass each answer a specific recorded finding.

Approving now: CI is fully green on this head, no PR-CI runs are pending, and the fork-refactor guardrail does not apply (test-type title). This approval supersedes the stale /review round-2 CHANGES_REQUESTED, whose single finding (R1-1) was fixed in 159611a6. The sponsored /verify report will land separately; if it surfaces anything, that is still actionable — this is one of two required approvals.

中文说明

反思:这是一次遗留项跟进的理想形态,而且评审轮次让它变得更好而非更臃肿。R1-1 修复不只是抽取辅助函数——它补上了两个原测试共享的真实隔离漏洞(遗漏的 readlink 打桩),TS2345 的绕行方式也是正确的(通过 vi.mocked 读回 spy,无抹除类型的强转)。drain 移除仍是本 PR 诚实的核心:任何测试都无法触达的代码被删除,不变式移入移除处的注释并点名唯一经过测试的咽喉点,人类评审对耦合的关切也得到回应而未重新引入机制。最终 diff 的每一行都有其存在理由——没有顺手改动,上次通过后的三个提交各自回应一条记录在案的具体发现。现在批准:该 head 的 CI 全绿、无 PR CI 在途、fork-refactor 门禁不适用(test 类型标题)。此批准取代过时的 /review round-2 CHANGES_REQUESTED——其唯一发现(R1-1)已在 159611a6 修复。赞助的 /verify 报告将单独发布;若其发现问题仍可处置——这只是两个必需批准中的一个。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent 3c": executing the two new tests to confirm they are green — the shared review worktree has no node_modules / dist , and npm ci + build exceeds this lens's tool b….

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):"agent 3c"executing the two new tests to confirm they are green — the shared review worktree has no node_modules / dist , and npm ci + build exceeds this lens's tool b…

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

Comment thread packages/core/src/config/config.test.ts Outdated
Comment on lines +688 to +691
const previousDebugLogFileEnv = process.env['QWEN_DEBUG_LOG_FILE'];
const previousSessionIdEnv = process.env['QWEN_CODE_SESSION_ID'];
const initialSessionId = '550e8400-e29b-41d4-a716-446655440000';
const rotatedSessionId = '7ba7b810-9dad-11d1-80b4-00c04fd430c8';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: This new test copies ~28 lines of load-bearing debug-fallback isolation boilerplate verbatim from the adjacent test does not replace the global debug fallback during daemon Config creation or rotation (config.test.ts:617): the four fs.promises spies (mkdir/appendFile/unlink/symlink), the QWEN_DEBUG_LOG_FILE/QWEN_CODE_SESSION_ID env save, and the entire finally teardown (mockRestore() ×4, resetDebugLoggingState(), setDebugLogSession(null), env restore). These spies are the only thing standing between the two tests and the real filesystem — the module-level vi.mock('node:fs') factory at config.test.ts:135 spreads ...actual and overrides only the sync fs API, so any un-spied fs.promises call runs the real implementation. If the debug fallback's fs surface changes (e.g. the alias/claim path gains a rename or stat call), both copies must gain the matching spy in lockstep; updating only one leaves that test doing real mkdir/appendFile writes into the actual global debug dir during a test run — the exact leakage this boilerplate exists to prevent. Neither copy spies fs.promises.readlink, so both tests already make a real (read-only) readlink against the actual debug dir — harmless today, but a shared helper is the natural place to make the isolation surface complete and single-sourced.

Witness:

A/B probe (scratch tree at this commit, only the new test's four spies neutralized):
  real writes landed in QWEN_RUNTIME_DIR —
  debug/7ba7b810-9dad-11d1-80b4-00c04fd430c8.txt (real "[CLI_ROTATION] post-rotation message" line)
  debug/latest -> 7ba7b810-9dad-11d1-80b4-00c04fd430c8.txt
Spies intact (committed code), same run: Tests 1 passed, RUNTIME DIR NEVER CREATED.

Extract a helper in this file used by both tests, e.g. async function withIsolatedDebugFallback(fn) that installs the four vi.spyOn(fs.promises, …) mocks (plus readlink to complete the surface), saves the two env vars, calls resetDebugLoggingState(), runs fn, and in finally restores spies, singleton state (resetDebugLoggingState(); setDebugLogSession(null)), and env — each test keeps only its scenario and assertions. The helper must keep vi.spyOn on the real fs.promises methods with mockRestore() teardown: the module-level factory at config.test.ts:137-150 returns {...actual} without overriding promises (unlike debugLogger.test.ts:27-37, whose vi.mock('node:fs') replaces promises with vi.fn()s), so vi.mocked(...)/clearAllMocks() alone would not isolate or uninstall them.

中文说明

[建议] 新测试从相邻测试 does not replace the global debug fallback during daemon Config creation or rotation(config.test.ts:617)逐字复制了约 28 行承重性的 debug-fallback 隔离样板:四个 fs.promises spy(mkdir/appendFile/unlink/symlink)、QWEN_DEBUG_LOG_FILE/QWEN_CODE_SESSION_ID 环境变量保存,以及整个 finally 清理(mockRestore() ×4、resetDebugLoggingState()setDebugLogSession(null)、环境变量恢复)。这些 spy 是这两个测试与真实文件系统之间唯一的屏障——config.test.ts:135 处的模块级 vi.mock('node:fs') 工厂展开 ...actual 且只覆盖同步 fs API,因此任何未被 spy 的 fs.promises 调用都会执行真实实现。如果 debug fallback 的 fs 调用面发生变化(例如 alias/claim 路径新增 renamestat 调用),两份副本必须同步补上对应的 spy;只更新其中一份,该测试就会在测试运行期间向真实的全局 debug 目录执行真实的 mkdir/appendFile 写入——正是这份样板所要防止的泄漏。两份副本都没有 spy fs.promises.readlink,因此两个测试其实都已经对真实 debug 目录执行了一次真实的(只读)readlink——目前无害,但抽取共享 helper 正是把隔离面补全并收敛到一处的自然位置。

验证证据:在同一提交的临时树中做 A/B 探针(仅禁用新测试的四个 spy):真实写入落进了 QWEN_RUNTIME_DIR——debug/7ba7b810-9dad-11d1-80b4-00c04fd430c8.txt(含真实的 "[CLI_ROTATION] post-rotation message" 日志行)以及真实符号链接 debug/latest -> 7ba7b810-9dad-11d1-80b4-00c04fd430c8.txt;spy 保持原样(提交代码)时同样运行:测试通过且运行目录从未被创建。

建议在本文件抽取一个两个测试共用的 helper,例如 async function withIsolatedDebugFallback(fn):安装四个 vi.spyOn(fs.promises, …) mock(并加上 readlink 以补全隔离面)、保存两个环境变量、调用 resetDebugLoggingState()、执行 fn,并在 finally 中恢复 spy、单例状态(resetDebugLoggingState(); setDebugLogSession(null))与环境变量——每个测试只保留自身场景与断言。helper 必须保留对真实 fs.promises 方法的 vi.spyOnmockRestore() 清理:config.test.ts:137-150 的模块级工厂返回 {...actual} 且未覆盖 promises(与 debugLogger.test.ts:27-37 不同,那里的 vi.mock('node:fs')promises 替换为 vi.fn()),因此仅靠 vi.mocked(...)/clearAllMocks() 无法隔离或卸载它们。

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

…1-1)

The two debug-fallback rotation tests copied ~28 lines of fs-spy + env
save/restore boilerplate verbatim, and both omitted a readlink spy — so
both already made a real readlink against the actual global debug dir, and
any future fs call added to the fallback/alias path would leak real writes
from whichever copy wasn't updated in lockstep. Extract
withDebugFallbackIsolation: it spies the full surface (mkdir/appendFile/
unlink/symlink/readlink) once, hands the body only the appendFile spy, and
restores env + logger state on exit. Behavior unchanged; mutation-verified
that the rotation-claim deletion still fails its test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tomsen02

Copy link
Copy Markdown
Contributor Author

R1-1 addressed in 36e3749fdb: extracted withDebugFallbackIsolation — it spies the full fs surface the fallback/alias path touches (mkdir/appendFile/unlink/symlink and readlink, the one both copies were missing) in one place, hands the body only the appendFile spy, and single-sources the env + logger teardown. Net −24 lines, and the two tests can no longer drift out of lockstep. Mutation-verified the rotation-claim deletion still fails its test; config suite 580/580.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not explored to full depth (tool budget reached): "agent 6b": execute the two debug-fallback tests in packages/core/src/config/config.test.ts — the review worktree has no node_modules and I declined a full monorepo install….

中文说明

未探索到全部深度(达到工具调用预算):"agent 6b"execute the two debug-fallback tests in packages/core/src/config/config.test.ts — the review worktree has no node_modules and I declined a full monorepo install…

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

Comment on lines +626 to +628
async function withDebugFallbackIsolation(
run: (appendFileSpy: ReturnType<typeof vi.spyOn>) => Promise<void>,
): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R1-1: (fix-induced) [fails-closed] [regression] The new withDebugFallbackIsolation helper types its callback as ReturnType<typeof vi.spyOn>, which resolves to vitest's generic-constraint overload MockInstance<(this: unknown, ...args: unknown[]) => unknown> — the concrete vi.spyOn(fs.promises, 'appendFile') spy is not assignable to it, so npm run build --workspace=packages/core fails with TS2345. The round-1 fix for R1-1 (extracting this shared helper) introduced it: every build or typecheck of packages/core — the CI Test job, npm run typecheck, npm run preflight, and dependent workspaces — exits non-zero at src/config/config.test.ts(650,17) on await run(appendFileSpy);. vitest transpiles without type-checking, so the two tests still run green and the failure is invisible to a test-only run; live CI on this commit is red with exactly this error.

Witness:

npm run build --workspace=packages/core → exit 1, single error:
src/config/config.test.ts(650,17): error TS2345: Argument of type
'MockInstance<(path: PathLike | FileHandle, data: string | Uint8Array<ArrayBufferLike>, ...) => Promise<...>>'
is not assignable to parameter of type 'MockInstance<(this: unknown, ...args: unknown[]) => unknown>'.
Identical failure in live CI: Test (ubuntu-latest, Node 22.x), run 33240215927.

Fix (two spots — repo precedent at packages/core/src/services/sessionService.test.ts:74):

// config.test.ts:8 — extend the existing type import
import type { Mock, MockInstance } from 'vitest';

// helper signature
async function withDebugFallbackIsolation(
  run: (appendFileSpy: MockInstance<typeof fs.promises.appendFile>) => Promise<void>,
): Promise<void> {

The annotation must stay assignable where the spy is passed — await run(appendFileSpy) at config.test.ts:650, with the spy produced as vi.spyOn(fs.promises, 'appendFile').mockResolvedValue(undefined) (config.test.ts:637-639) and consumed in both test bodies via toHaveBeenCalledWith(path, expect.stringContaining(...), 'utf8') — so it must not be widened to a bare Mock/vi.fn() type that drops the call-argument typing.

Acceptance criterion: npm run build --workspace=packages/core must go green — it is red today with TS2345 at config.test.ts:650, and reverting the annotation to ReturnType<typeof vi.spyOn> reproduces the failure (no runtime test pins a type annotation, so the build itself is the mutation check).

中文说明

R1-1:(修复引入)新的 withDebugFallbackIsolation 辅助函数把回调参数标注为 ReturnType<typeof vi.spyOn>,它解析到 vitest 的泛型约束重载 MockInstance<(this: unknown, ...args: unknown[]) => unknown> —— 具体的 vi.spyOn(fs.promises, 'appendFile') spy 无法赋给该类型,导致 npm run build --workspace=packages/core 报 TS2345 失败。本缺陷由第 1 轮 R1-1 的修复(提取这个共享辅助函数)引入:所有对 packages/core 的构建或类型检查 —— CI 的 Test 任务、npm run typechecknpm run preflight、以及依赖它的工作区 —— 都会在 src/config/config.test.ts(650,17)await run(appendFileSpy); 处非零退出。vitest 转译时不做类型检查,所以这两个测试仍然绿灯,仅跑测试看不到该失败;当前提交上的 CI 正是因为这个错误而红。

修复(两处 —— 仓库先例见 packages/core/src/services/sessionService.test.ts:74):把类型导入扩展为 import type { Mock, MockInstance } from 'vitest';,并把辅助函数签名改为 run: (appendFileSpy: MockInstance<typeof fs.promises.appendFile>) => Promise<void>

约束:该标注必须保持可赋值 —— await run(appendFileSpy)(config.test.ts:650)处的 spy 由 vi.spyOn(fs.promises, 'appendFile').mockResolvedValue(undefined)(config.test.ts:637-639)产生,并被两个测试体以 toHaveBeenCalledWith(path, expect.stringContaining(...), 'utf8') 消费 —— 因此不能放宽为丢失调用参数类型的裸 Mock/vi.fn() 类型。

验收标准:npm run build --workspace=packages/core 必须变绿 —— 当前因 config.test.ts:650 的 TS2345 而红,把标注还原为 ReturnType<typeof vi.spyOn> 会复现该失败(类型标注没有运行时测试可钉,构建本身就是变异检验)。

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

The helper typed its callback arg as ReturnType<typeof vi.spyOn>, which
resolves to vi.spyOn's generic-overload return type; the concrete
appendFile spy is not assignable to it, so tsc/build/CI failed with TS2345
at the call site (vitest transpiles without type-checking, so the tests
still ran green and the failure was invisible to a test-only run — I missed
it by not running typecheck on the previous commit). Drop the callback arg;
the two tests read the spy back via vi.mocked(fs.promises.appendFile), which
is correctly typed. appendFile folded into the spies array.

Verification: tsc --noEmit exit 0, config suite 580/580, rotation-claim
mutation still fails its test, ESLint + Prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tomsen02

Copy link
Copy Markdown
Contributor Author

Fixed in 159611a60b. You're right — the helper's ReturnType<typeof vi.spyOn> callback type resolved to the generic-overload return type that the concrete appendFile spy isn't assignable to, so tsc/build/CI failed with TS2345 even though vitest (transpile-only) ran green. My mistake was verifying the helper-extraction commit with vitest + eslint but not typecheck — eslint doesn't catch a type-assignability error.

Fix: dropped the callback arg entirely; the two tests read the spy back via vi.mocked(fs.promises.appendFile), which is correctly typed, and appendFile is folded into the spies array. tsc --noEmit now exits 0, config suite 580/580, the rotation-claim mutation still fails its test, ESLint + Prettier clean.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": empirical mutation check — removing the sessionIdContext.getStore() === undefined re-claim branch in config.ts:startNewSession and re-running the test to pr…; "agent reverse-audit (round 2)": repeated soak with full error capture of the flake (15 post-failure runs all passed, so the full failure message was never captured).

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

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/core/src/config/config.test.ts:708 — [probe] rotation test never asserts the interloper steals the fallback
中文说明

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"empirical mutation check — removing the sessionIdContext.getStore() === undefined re-claim branch in config.ts:startNewSession and re-running the test to pr…"agent reverse-audit (round 2)"repeated soak with full error capture of the flake (15 post-failure runs all passed, so the full failure message was never captured)

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

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

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

@CanReader CanReader left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting defensive code is the right call when it genuinely cannot execute, and the reasoning here holds up as far as I could follow it statically.

The claim is that every path into drainNonInteractiveQueue already runs outside the session-id store, so the inner sessionIdContext.exit was dead. The pieces are where the comment says they are at this commit: startNonInteractiveOpenAILogHousekeeping wraps its enqueue and worker start in sessionIdContext.exit (scheduler.ts:197), the worker's re-kick hangs off the .finally at 257, and the retry timers are scheduled from inside the drain itself (357). Since AsyncLocalStorage.exit runs its callback outside the store and anything scheduled inside that callback inherits the outside context, the chain does hold — a timer registered within the exited scope fires context-free, so the drain and everything it schedules stay outside the store without a second wrapper.

The part I like is that this is framed as removing something no test could pin. That is the honest reason to delete defensive code, and it is better than the usual "this looked redundant". Keeping the explanation as a comment at the site is the right trade: the next person to read this will wonder why there is no exit here, and now they will not have to re-derive the answer.

One suggestion. The invariant that makes this safe lives at the call site (line 197), not here, and nothing fails if someone later adds a fourth way into the drain that does not go through the exited scope — the code would silently start propagating a session id into housekeeping work. If there is a cheap way to assert it, an invariant check in the drain (sessionIdContext.getStore() === undefined) behind a debug/dev guard would turn a silent regression into a visible one without reintroducing the wrapper you are removing. If that is more machinery than it is worth, a pointer in this comment to scheduler.ts:197 as the choke point would at least make the coupling greppable.

I reviewed this statically and did not run the housekeeping suite.

…d-2 suggestion)

The invariant that makes the removed drain-side sessionIdContext.exit safe
lives at the start-side exit, not here. Spell that out and warn that a new
entry into the drain must go through the exited scope — makes the coupling
greppable, per the review suggestion. Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tomsen02

Copy link
Copy Markdown
Contributor Author

Took the cheap option in a6cab44148 (comment-only): the drain's no-exit comment now names the start-side sessionIdContext.exit as the single choke point and warns that any new entry into the drain must go through that exited scope. Makes the coupling greppable without reintroducing the wrapper. Skipped the dev-guard invariant check as more machinery than it's worth for a module-private drain with three known entries. (The TS2345 from the earlier round is already fixed in 159611a60b.)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

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

@wenshao

wenshao commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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: 40 passed · 0 failed · 40 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:40 通过 · 0 失败 · 40 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #10465 Deep Verification — merge-ready

Verdict: merge-ready — 40/40 scripted assertions passed (0 unexpected failures). Verified head: a6cab44148245a34d029b189ecd01a2a68a8942a (git rev-parse HEAD^2), merge commit 29d53aa, A/B base 1db18fe (HEAD^1).

中文摘要

结论merge-ready,40/40 脚本化断言全部通过,未发现任何阻塞项。

A/B 结论

  • 变异矩阵(表「Mutation matrix」):fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930 第 3/4 轮遗留的两个变异体在本 PR 之前均存活(base 测试 638/638 全绿),加入新测试后各自被其点名测试精确杀死(各仅 1 个测试失败,且失败断言正是预期的行为差异——rotation 变异体的失败现场显示日志确实写进了闯入者会话的文件)。两个正向对照变异体在两臂均被捕获,证明杀死归因于测试本身而非环境。
  • 生产改动(表「cli A/B」):drain 侧 sessionIdContext.exit 包装的移除经真实文件系统 + 真实 AsyncLocalStorage 的 harness 证实为死代码——base 臂上该包装触发时 store 已为 undefined(什么都没剥离),两臂的可观察结果(清理执行、调试日志落入 fallback 会话文件、绑定会话文件零写入)完全一致;14 项既有调度器测试两臂均 14/14。

Findings:无。仅有一处对描述的更正(测试总数因 base 前移为 640,而非描述中的 621,见 Corrections)。

未覆盖范围:逐提交归因(浅克隆 depth 2,4 个提交仅 head 可达)、acpAgent.test.ts(整体 mock 调度器模块,无本 PR 信号)、ESLint 门禁、真实计时器下的重试定时器路径(以两臂假定时钟套件等价 + ALS 继承语义佐证)、Windows/macOS。

Scope

This PR closes three deferred test gaps recorded in #9930's review rounds 3–4: two become mutation-killing tests (core), and the third — the drain-side sessionIdContext.exit wrapper in the cli housekeeping scheduler — is removed as unreachable defensive code. It touches 3 files; the only production change is scheduler.ts.

  • Central claim 1 (tests): each new test kills exactly the deferred mutant that survived fix(core): stop daemon Configs and background work from hijacking the debug-log session #9930's suites, and the mutants survive the base suites (reproducing the deferral).
  • Central claim 2 (production change): removing the drain-side wrapper is behavior-preserving on every reachable path; the inner returncontinue is equivalent.
  • Secondary claims: the shared withDebugFallbackIsolation helper preserves the existing daemon-fallback test's pinning power; the 14 unchanged scheduler tests still pass against the simplified drain.

Mutation matrix (central claim 1)

Production sources are identical on both arms (the PR changes no core production code), so the matrix isolates what the new tests pin that the old tests did not. Mutants are single-guard surgical mutations applied with exact-match replacement (harnesses/apply-mutant.mjs); base test files are extracted from HEAD^1 via git show. Working tree restored clean after every cell (asserted per cell).

cell mutant tests total failed failing tests
C-head M0 (none) head 640 0
C-base M0 (none) base 638 0
M1-head delete rotation-path setDebugLogSession(this) guard head 640 1 claims the global debug fallback on un-contexted rotation (single-session CLI) (the new test)
M1-base same base 638 0 survived — reproduces round-4 deferral
M2-head insert latch if (aliasFailureStreak >= MAX…) return; before alias scheduling head 640 1 recovers from the streak cap when a later alias update succeeds (the new test)
M2-base same base 638 0 survived — reproduces round-3 deferral
M3-head delete constructor-path claim guard (positive control) head 640 3 daemon-fallback test + 2 goalTokenBudget debug-log tests
M3-base same base 638 3 same 3 tests — attribution identical across arms
M4-head aliasFailureStreak += 1+= 0 (positive control) head 640 2 persistent-failure cap test + the NEW recovery test
M4-base same base 638 1 persistent-failure cap test only

Re-run: node tmp/pr10465-verify-20260901-015430/harnesses/run-matrix.mjs — 22/22 scripted expectations passed. Capture: 01-mutation-matrix-base-vs-head.png.

Reading:

  • Both deferred mutants flip 0→1 kills exactly at their named new tests; no mutant regressed from killed to survived; attribution is exact (the failing set under M1/M2 contains nothing else).
  • The kill messages fail on the intended behavioral values, not setup: M1's failure shows appendFile expected at 7ba7b810-….txt (rotated session) but actually called with 6ba7b810-….txt (the interloper) — the precise misrouting the guard prevents (raw/kill-message-M1-head.txt). M2 fails with "expected spy to be called 4 times, but got 3 times" at the session-change phase — the latch refusing the breaker's attempt (raw/kill-message-M2-head.txt).
  • Bonus pin the PR did not claim: the new recovery test's first phase asserts the marker goes sticky at the cap, so it also kills M4 on head (M4: 1→2 kills) — the cap increment was previously pinned only by the persistent-failure test.
  • Positive controls (M3/M4) land in the same files as their mutants and are caught on both arms — the kills above are collection-attribution, not luck. The M3 blast radius (3 tests) is expected: the constructor claim also feeds the two goalTokenBudget debug-log tests, which exist identically in the base file.

cli A/B: drain-side wrapper removal (central claim 2)

cell scheduler.ts observable oracle result
base 1db18fe wrapper present — 2 live sessionIdContext.exit call sites non-interactive-scheduler.test.ts 14/14 PASS
head a6cab44 wrapper removed — 1 live call site same suite 14/14 PASS
base harness exit-spy stores: ["22222222-…", null] real cleanup ran (old file deleted, fresh kept); drain's HOUSEKEEPING line in FALLBACK session file; bound session file empty 9/9 asserts
head harness exit-spy stores: ["22222222-…"] identical observables 8/8 asserts

Harness: harnesses/context-leak-harness.mjs, driven with npx tsx against each tree's real source — real fs (temp QWEN_HOME/QWEN_RUNTIME_DIR), a real 12-day-old log fixture, no mocks of the unit under test. It wraps sessionIdContext.exit with a recording spy and kicks startNonInteractiveOpenAILogHousekeeping from inside sessionIdContext.run('22222222-…') — the daemon-shape worst case, matching the real production call sites (acpAgent.ts:13280, acpAgent.ts:13549, the only callers besides tests).

The base-arm observation is the load-bearing one: the drain-side exit call fired with the store already undefined — the wrapper stripped nothing, so it was dead code, and its removal changes no observable. The debug-routing oracle corroborates without trusting the spy: had any context leaked into the drain, getActiveSession() would have routed the drain's openai-logs: removed=1 errors=0 completed=true line into the bound session's file; on both arms it landed only in the fallback session's file. Captures: 02-head-arm-context-harness.png, 03-base-arm-context-harness.png, 04-cli-ab-cells.png.

Remaining coverage of the removed layer, per the skill's layered-guard rule: the hazard (session context leaking into process-scoped housekeeping) is still pinned by the start-side exit, which two existing tests exercise (keeps process-scoped cleanup outside session contexts, keeps a failing start outside the spawning session context — passing on both arms), plus harness scenario 1 above. The classification here is stronger than "redundant defence": the removed guard was proven no-op on base, not merely shadowed.

returncontinue: statically, the drain loop is while (!nonInteractiveStopping) with the branch inside try/finallycontinue runs the finally (clearing activeNonInteractiveJob) before the condition re-check exits, exactly what the base callback-return did; behaviorally, aborts the active scan during stop drives this exact branch (stop set mid-iteration) and passes on both arms.

Corrections

  • The description's test counts ("621/621", "config 580/580 + debugLogger 41/41") were measured against an older base. The metadata baseRefOid (d3ab6ea9) differs from the merge ref's base tip (HEAD^1 = 1db18fe), which is not present locally — the base moved under the PR. At this head the two suites hold 599 + 41 = 640 tests (base arm: 638). All claimed properties hold at the corrected counts. This is a correction to the description's numbers, not a request to change code.

Findings

None. Every claim the PR makes was measured and held; no unexpected failure occurred in any cell.

Not covered

  • Per-commit attribution: metadata lists 4 commits; the checkout is depth 2 and shallow, so only head commit a6cab44 is locally reachable (git rev-list HEAD^1..HEAD^2 = 1, grafted). Verified the aggregate HEAD^1..HEAD diff instead. Commits cef2d56, 36e3749, 159611a were not individually exercised.
  • acpAgent.test.ts (531 tests, cited in the PR commit message): not re-run — it mocks the scheduler module wholesale (vi.mock('../services/housekeeping/scheduler.js')), so it carries no signal about this PR's change; its call-site contract is exercised by harness scenario 1 instead.
  • ESLint/Prettier gates: not run locally (CI covers; the production delta is a deletion plus a comment).
  • Real-timer retry re-entry (60 s lock-retry / 24 h recurring timers): not driven in real time; covered by the fake-timer suite passing identically on both arms plus AsyncLocalStorage timer-inheritance semantics (timers are registered inside the already-context-free drain body on both arms).
  • Windows/macOS: linux container only; the symlink/alias paths are mocked in the unit tests regardless.
  • Full workspace suites: only the affected test files ran (scope choice); the PR's own CI covers the rest.
  • screen.diff in the verify context matches the effective diff's content lines byte-for-byte; only hunk offsets and blob hashes differ, reflecting the PR's older original base (base drift, see Corrections).

Methodology

Environment: CI verify container (node:22-bookworm, node v22.23.2), merge-ref checkout at depth 2, npm ci + npm run build pre-done. Mutation matrix ran in the main tree with exact-match single-file surgery (match-count asserted, git checkout -- restore, tree-clean asserted per cell) under vitest run --reporter=json. The base arm for cli used git worktree add tmp/base-tree HEAD^1 wired to the already-installed root node_modules via symlinks (clean control: the PR changes zero package manifests, asserted); the realpath of tmp/base-tree/packages/cli/node_modules resolves to the main tree, which is harmless here because cli's vitest config aliases @qwen-code/qwen-code-core to the worktree's own core source (unchanged by this PR) and the scheduler under test loads from the worktree via relative imports — the guard-satisfied dist/ copies belong to packages this PR does not touch (verified against git diff --name-only HEAD^1..HEAD). Typecheck gates re-ran tsc --noEmit for packages/cli and packages/core inside the roll-up. Raw per-cell vitest JSON/logs: raw/vitest-*.{json,log}; harness logs: raw/harness-{head,base}.log; roll-up: raw/rollup-run.log; the scratch worktree was removed after the A/B cells were captured.

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/core/src/config/config.test.ts: (cd packages/core) npx --no-install vitest run ./src/config/config.test.ts
file packages/core/src/utils/debugLogger.test.ts: (cd packages/core) npx --no-install vitest run ./src/utils/debugLogger.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/config/config.test.ts: PPPPP
  packages/core/src/utils/debugLogger.test.ts: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/config/config.test.ts: P (exit 0)
round 1 · packages/core/src/utils/debugLogger.test.ts: P (exit 0)
round 2 · packages/core/src/config/config.test.ts: P (exit 0)
round 2 · packages/core/src/utils/debugLogger.test.ts: P (exit 0)
round 3 · packages/core/src/config/config.test.ts: P (exit 0)
round 3 · packages/core/src/utils/debugLogger.test.ts: P (exit 0)
round 4 · packages/core/src/config/config.test.ts: P (exit 0)
round 4 · packages/core/src/utils/debugLogger.test.ts: P (exit 0)
round 5 · packages/core/src/config/config.test.ts: P (exit 0)
round 5 · packages/core/src/utils/debugLogger.test.ts: P (exit 0)

Evidence images

01-mutation-matrix-base-vs-head

02-head-arm-context-harness

03-base-arm-context-harness

04-cli-ab-cells

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — built and run locally against a real merged tree

I rebuilt this PR locally, merged it onto current main, and re-derived every claim in the description from scratch rather than reading the diff. Summary: all three claims hold, 11/11 mutants die, and the production change is a behavioural no-op on every entrance that exists today. I recommend merging.

Environment. PR head a6cab44148 merged onto origin/main 1db18fefdb (clean merge, 182 insertions(+), 79 deletions(-) — identical to the PR diff). macOS 15 / Darwin 25.6.0, Node 24.18.1, fresh npm ci + full npm run build (exit 0, so tsc --build covers the merged tree). Prettier and ESLint clean on all three changed files.

1. The stated test runs reproduce

suite claimed measured on the merged tree
core: config.test.ts + debugLogger.test.ts 621/621 640/640 (599 + 41; main has grown since the PR's base)
cli: non-interactive-scheduler.test.ts 14/14 14/14
cli: + scheduler.test.ts + acpAgent.test.ts 531/531 604/604 (564 + 26 + 14)

2. Mutation matrix — 11 mutants, 0 survivors, and the counterfactual holds

I ran the two mutants the PR names, plus nine more around the same two guards, against both the PR-head suites (640 tests) and the pre-PR suites (origin/main versions of the two test files, 638 tests). The counterfactual is the part that actually proves the PR's value:

  • C-M1 (delete the rotation-path setDebugLogSession(this) guard) — survives all 638 pre-PR tests, and at PR head is killed by exactly one test: claims the global debug fallback on un-contexted rotation (single-session CLI). Claim confirmed verbatim.
  • D-M1 (turn the streak cap into a never-retry latch) — survives all 638 pre-PR tests, and at PR head is killed by exactly one test: recovers from the streak cap when a later alias update succeeds. Claim confirmed verbatim.

The other nine were already covered, but the two new tests are not narrow one-mutant pins: the streak test also fires on D-M2 (success stops resetting the streak), D-M3 (off-by-one <=), D-M4 (streak never increments), D-M5 (reset helper stops clearing) and D-M7 (cap 3→2). The rotation pair is two-sided: inverting the guard (C-M3) fails both fallback tests, and making either claim unconditional (C-M2, C-M4) fails the daemon-side test.

mutation matrix

live mutation run

3. The production change: verified as a no-op, and the residual risk measured

This is the only part of the PR that ships. The argument in the comment is a static one, so I checked it dynamically instead. I wrote an AsyncLocalStorage ledger probe that records sessionIdContext.getStore() inside drainNonInteractiveQueue on every entrance, then ran the identical probe file against two arms of the same tree — only scheduler.ts swapped (BASE = origin/main, with the wrapper; HEAD = this PR, without it).

# entrance driven BASE HEAD
P1 start-side kick from inside sessionIdContext.run <none> <none> same
P3 FIFO pickup of a job enqueued by a second session <none> <none> same
P4 stop() mid-flight with a second job still queued <none> <none> same
P5 fresh/locked reschedule delays 60000ms 60000ms same
P6 real event-loop dispatch of the retry timer <none> <none> same
P2 synthetic: retry callback hand-fired from inside a bound context <none> S-timer-firer differs

Five of six ledgers are byte-identical. P6 is the one that matters most: it starts housekeeping from a bound, ACP-shaped context exactly as acpAgent.ts does, lets a job throw, and lets the 10-minute retry timer actually fire from the event loop (delay shortened, dispatch untouched) — the drain re-entry is context-free in both arms. So the induction in the comment is empirically sound, and the removal changes nothing on any path that exists today.

P4 also settles the returncontinue swap behaviourally: with a job still queued, stop() aborts the in-flight job and the queued job never runs, identically in both arms.

A/B ledger

The single differing row is the honest measurement of what @CanReader flagged. P2 is not a production path — I hand-invoked the retry callback from inside sessionIdContext.run to synthesise "a future caller that enters the drain without going through the start-side exit". BASE strips it; HEAD propagates the session id into process-scoped housekeeping, silently. That is the exact size of the residual risk: zero today, one silent regression the day someone adds a fourth entrance.

4. Bonus: the round-1 helper's readlink spy is genuinely load-bearing

Commit 36e3749fdb justified extracting withDebugFallbackIsolation partly on "both omitted a readlink spy … both already made a real readlink against the actual global debug dir". I checked that empirically with a recorder installed at module scope (so it only sees calls that escape the test's own vi.spyOn). With the helper as shipped: zero real fs calls. With just the readlink spy line deleted: the two tests still pass, but make 2 real readlink calls against my actual ~/.qwen/debug/latest. The stated reason for the refactor is true, and the leak was silent.

readlink leak A/B

Observations (non-blocking, not merge conditions)

  • O1 — two of the three drain entrances remain unpinned in CI. The suite's context test (keeps process-scoped cleanup outside session contexts) only covers the start-side kick. My P6 shows the retry-timer path is context-free today, but nothing in the repo would catch it regressing. If you want the cheapest possible closure of @CanReader's point, either a P6-shaped test or the one-line sessionIdContext.getStore() === undefined dev assertion he proposed would do it; the comment-only resolution in a6cab44148 is defensible given P1–P6 above, so I would not hold the PR for it.
  • O2 — no other concerns. CI on the PR is 17 success / 43 skipped, no failures.

Verdict

Every claim in the description reproduced under a real build. The two new tests are the thing that closes the two gaps (proven by the pre-PR counterfactual, not asserted), and the one production change is a measured no-op. Ship it.

Reproduction artifacts (probe, mutation script, raw ledgers, raw matrix output): wenshao/qwen-code@assets-pr10465.

中文说明

维护者本地验证 —— 真实构建 + 合并树实跑

我在本地把这个 PR 合到当前 main 上重新构建,并且不看结论、逐条重新推导了描述里的每个断言。结论:三条断言全部成立,11 个变异体全被杀死,唯一的生产改动在今天存在的所有入口上都是行为等价的空操作。 建议合入。

环境:PR head a6cab44148 合并 origin/main 1db18fefdb(干净合并,182 插入 / 79 删除,与 PR diff 完全一致)。macOS 15 / Darwin 25.6.0,Node 24.18.1,全新 npm ci + 完整 npm run build(exit 0,即 tsc --build 已覆盖合并树)。三个改动文件 Prettier / ESLint 干净。

1. 声明的测试全部复现

套件 声明 合并树实测
coreconfig.test.ts + debugLogger.test.ts 621/621 640/640(599 + 41;main 自 PR 基线以来有增长)
clinon-interactive-scheduler.test.ts 14/14 14/14
cli+ scheduler.test.ts + acpAgent.test.ts 531/531 604/604(564 + 26 + 14)

2. 变异矩阵 —— 11 个变异体、0 存活,且反事实成立

我把 PR 点名的两个变异体、加上围绕同两处守卫的另外九个,分别对 PR head 套件(640 条)PR 之前的套件(两个测试文件的 origin/main 版本,638 条) 各跑一遍。真正能证明这个 PR 有价值的是反事实那一半:

  • C-M1(删掉 rotation 路径的 setDebugLogSession(this) 守卫)—— 在 638 条 PR 前测试下全部存活;在 PR head 下被且仅被一条测试杀死:claims the global debug fallback on un-contexted rotation (single-session CLI)。断言逐字复现。
  • D-M1(把 streak 上限变成永不重试的闩锁)—— 在 638 条 PR 前测试下全部存活;在 PR head 下被且仅被一条测试杀死:recovers from the streak cap when a later alias update succeeds。断言逐字复现。

另外九个原本就有覆盖,但两条新测试并不是只钉一个变异体的窄用例:streak 那条同时对 D-M2(成功不再重置 streak)、D-M3<= 差一)、D-M4(streak 从不自增)、D-M5(reset 不再清零)、D-M7(上限 3→2)报红。rotation 那对是双向约束:把守卫取反(C-M3)会同时打红两条 fallback 测试;把任一处 claim 改成无条件(C-M2C-M4)则打红 daemon 侧那条。

3. 生产改动:实测为空操作,并把残留风险量化

这是 PR 里唯一会上线的部分。代码注释给的是静态论证,所以我改用动态方式核对:写了一个 AsyncLocalStorage 流水账探针,在 drainNonInteractiveQueue 的每次进入处记录 sessionIdContext.getStore(),然后用完全相同的探针文件跑同一棵树的两臂,只替换 scheduler.ts(BASE = origin/main,带包装;HEAD = 本 PR,去掉包装)。

# 驱动的入口 BASE HEAD
P1 sessionIdContext.run 内的 start 侧启动 <none> <none> 相同
P3 第二个会话入队的 job 被 FIFO 取走 <none> <none> 相同
P4 在飞任务中途 stop(),且仍有第二个 job 排队 <none> <none> 相同
P5 fresh/locked 的重排延迟 60000ms 60000ms 相同
P6 重试定时器的真实事件循环派发 <none> <none> 相同
P2 合成:在绑定上下文内手工调用重试回调 <none> S-timer-firer 不同

六个流水账里五个逐字节相同。P6 最关键:它按 acpAgent.ts 的生产形态在绑定上下文里启动 housekeeping,让任务抛错,再让那个 10 分钟重试定时器真的由事件循环派发(只缩短延迟,不改派发方式)—— 两臂下重入 drain 都是无上下文的。所以注释里的归纳论证在实测上成立,这次移除在今天存在的任何路径上都不改变行为

P4 同时从行为上结清了 returncontinue 的替换:仍有排队 job 时调用 stop(),在飞任务被 abort、排队 job 不再执行,两臂完全一致。

那唯一不同的一行,正是 @CanReader 所提风险的诚实测量值。P2 不是生产路径 —— 我在 sessionIdContext.run 里手工触发重试回调,用来合成"未来某个不经过 start 侧 exit 就进入 drain 的调用方"。BASE 会把上下文剥掉;HEAD 会把 session id 静默带进进程级 housekeeping。这就是残留风险的确切大小:今天为零,等到有人加第四个入口那天变成一次静默回归。

4. 附带核实:round-1 helper 里的 readlink spy 确实是承重件

commit 36e3749fdb 抽取 withDebugFallbackIsolation 的理由之一是"两处都漏了 readlink spy……都已经在对真实全局 debug 目录做真实 readlink"。我用一个装在模块作用域的记录器做了实测(因此它只能看到逃过测试自身 vi.spyOn 的调用):按现状的 helper —— 真实 fs 调用 0 次;仅删掉 readlink spy 那一行 —— 两条测试依然通过,但对我真实的 ~/.qwen/debug/latest 发起了 2 次真实 readlink。重构给出的理由属实,而且这个泄漏是静默的。

观察项(非阻塞,不作为合入条件)

  • O1 —— drain 的三个入口里有两个在 CI 中仍未被钉住。 现有上下文测试(keeps process-scoped cleanup outside session contexts)只覆盖 start 侧启动。我的 P6 证明重试定时器路径当前是无上下文的,但仓库里没有任何东西能在它回归时报红。若想用最低成本收掉 @CanReader 那条,加一个 P6 形态的测试、或他建议的那行 sessionIdContext.getStore() === undefined 开发期断言都可以;鉴于上面 P1–P6 的结果,a6cab44148 里"只加注释"的处理是站得住的,我不会为此卡住这个 PR。
  • O2 —— 无其他问题。 PR 的 CI 为 17 成功 / 43 跳过,无失败。

结论

描述中的每条断言都在真实构建下复现。两条新测试确实是收掉这两个缺口的原因(由 PR 前反事实证明,而非声称),唯一的生产改动经测量为空操作。建议合入。

复现物料(探针、变异脚本、原始流水账、原始矩阵输出):wenshao/qwen-code@assets-pr10465

@wenshao
wenshao added this pull request to the merge queue Sep 1, 2026
Merged via the queue into QwenLM:main with commit 39ca959 Sep 1, 2026
79 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.0.

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