Skip to content

fix(core,cli): drain background notifications outside the subagent's ALS frame - #7194

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/7156-notification-als-leak
Jul 21, 2026
Merged

fix(core,cli): drain background notifications outside the subagent's ALS frame#7194
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/7156-notification-als-leak

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

Stops a background subagent's model from leaking into the main session when its completion notification drains. Two layers: the decisive consumer guard — the notification drain effect in useGeminiStream now runs inside a new runOutsideAgentContext() helper (AsyncLocalStorage.exit), so the drained turn and every async continuation it starts resolve Config.getModel() to the main session's configuration regardless of which producer's setState triggered the React commit; and a producer defenseBackgroundTaskRegistry.emitNotification invokes the notification callback with no agent frame on the stack, pinned by a unit regression test. The helper is exported from core for reuse by other main-session-owned paths that can be triggered from inside an agent frame.

Why it's needed

#7156: a session on a large-context model launching a background subagent on a smaller model got a 400 on the notification turn — the accumulated history was sent to the subagent's model. #7119 fixed a different path to the same symptom (override clearing); here the model resolution itself was mis-scoped. The mechanism, confirmed with a deterministic reproducer: progress setState calls issued from inside the subagent's AsyncLocalStorage frame can be batched by React/Ink into the same commit as the notification trigger; the drain effect then executes on that batch's synchronous stack, and everything submitQuery starts inherits the subagent's runtime view. The "persistent" model switch users observed (all turns after the notification wrong, status line flickering) is each subsequent turn deriving from the contaminated drain chain — which is also why guarding a single producer is a whack-a-mole; the consumer guard closes the class. Credit to @Aleks-0's instrumented traces on the issue for ruling out persistent ModelsConfig mutation and pinning the ALS mechanism.

Reviewer Test Plan

How to verify

  1. Configure a session model with a large context and a custom agent (.qwen/agents/worker.md) with model: pointing at a smaller-context model.
  2. Ask the model to launch that agent with run_in_background: true; wait for completion and the notification to drain.
  3. Before this PR: the notification turn — and every call after it — goes out on the subagent's model (400 once history exceeds its window; status line shows the wrong model). After: everything stays on the session model.

Automated coverage: a unit regression test proves the notification callback previously ran inside the subagent's frame (getCurrentAgentId()/runtimeView set) and now runs with none — it fails on the unpatched registry. useGeminiStream suite 169/169, background-tasks suite 111/111, npm run typecheck, eslint, prettier all clean.

Evidence (Before & After)

Deterministic E2E: PTY harness drives the real bundle against a local fake OpenAI-compatible server logging the model of every request. Session model big-context; project-level custom agent worker with model: small-default; scripted turn launches it in background:

# Request before fix after fix
1 user turn → agent tool call big-context big-context
2 tool-result continuation big-context big-context
3 subagent's own turn small-default (its model ✓) small-default (unchanged ✓)
4 system call before the notification big-context big-context
5 notification turn small-default big-context
6 system call after the notification small-default ❌ (persistent leak) big-context

Row 6 shows the fix also ends the "session permanently switched" aspect — the contamination chain is severed at the drain.

Tested on

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

Environment (optional)

macOS (Darwin 24.6), Node v22.23.1; PTY harness + local fake OpenAI-compatible SSE server against the esbuild bundle, plus vitest unit tests.

Risk & Scope

  • Main risk or tradeoff: the drain effect body now executes via AsyncLocalStorage.exit — a no-op when no frame is active (the common case). Notification semantics are unchanged; only the async-context scoping of the drained turn changes.
  • Not validated / out of scope: the compaction-on-leaked-model consequence reported later in Bug: Subagent mutates main session model — context overflow recurrence after #7119 #7156 is expected to be fixed by the same guard (compaction for the drained turn now runs outside the frame) but was not separately reproduced; other main-session paths that might run inside an agent frame (e.g. teammate drains) can adopt the exported helper in follow-ups.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #7156

中文说明

本 PR 做了什么

阻止后台子 agent 的模型在完成通知汇入时泄漏进主会话。两层防护:决定性的消费端防线——useGeminiStream 的通知 drain effect 整体运行在新的 runOutsideAgentContext()AsyncLocalStorage.exit)内,无论哪个 producer 的 setState 触发了这次 React commit,汇入轮次及其启动的所有异步延续都以主会话配置解析 Config.getModel();以及生产端防御——BackgroundTaskRegistry.emitNotification 在无 agent frame 下调用通知回调,由单测回归固定。helper 从 core 导出,供其它可能在 agent frame 内被触发的主会话路径复用。

为什么需要

#7156:大上下文会话启动小上下文后台子 agent,通知轮次 400——累积历史被发给了子 agent 的模型。#7119 修复的是同一症状的另一条路径(override 清除);这里是模型解析本身作用域错误。经确定性复现器证实的机制:子 agent ALS frame 内发出的进度 setState 会被 React/Ink 与通知触发合并进同一次 commit;drain effect 在该批次的同步栈上执行,submitQuery 启动的一切都继承子 agent 的 runtime view。用户观察到的「持久」切换(通知后所有轮次都错、状态栏闪烁)是每个后续轮次都从被污染的 drain 链派生——这也是只防单个 producer 属于打地鼠的原因;消费端防线关闭整类问题。感谢 @Aleks-0 在 issue 中的插桩记录排除了持久 ModelsConfig mutation 并锁定 ALS 机制。

审阅测试计划

如何验证

  1. 会话配大上下文模型,自定义 agent(.qwen/agents/worker.md)的 model: 指向小上下文模型;
  2. 让模型以 run_in_background: true 启动该 agent,等完成与通知汇入;
  3. 本 PR 之前:通知轮次及其后所有调用走子 agent 模型(历史超窗即 400、状态栏显示错误模型);之后:全部保持会话模型。

自动化覆盖:单测证明通知回调此前运行在子 agent frame 内、现在无 frame(在未修复的 registry 上失败);useGeminiStream 169/169、background-tasks 111/111、typecheck / eslint / prettier 全绿。

证据(Before & After)

确定性 E2E(PTY + 伪 OpenAI 服务器记录每请求 model;会话 big-context、自定义 agent model: small-default、后台启动):修复前 #5 通知轮次与 #6 后续系统调用均为 small-default(❌ 持久泄漏);修复后全部保持 big-context(✅)——第 6 行同时证明「会话被永久切换」的表象随 drain 链被切断而消失。

测试平台

macOS 已本地验证(✅);Windows / Linux 依赖 CI(⚠️)。

环境

macOS(Darwin 24.6)、Node v22.23.1;PTY harness + 本地伪 OpenAI SSE 服务器 + vitest 单测。

风险与范围

  • 主要风险/权衡:drain effect 主体经 AsyncLocalStorage.exit 执行——无 frame 时是 no-op(常态)。通知语义不变,仅改变汇入轮次的异步上下文作用域。
  • 未验证/超出范围:issue 后续报告的「压缩在泄漏模型上运行」预计由同一防线修复(汇入轮次的压缩现在也在 frame 外运行)但未单独复现;其它可能在 agent frame 内运行的主会话路径(如 teammate drain)可在后续采用该导出 helper。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #7156

🤖 Generated with Claude Code

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections filled with substantive content, including the Chinese translation.

Problem: this is a real, observed bug. Issue #7156 (P1) documents the model leak with detailed evidence — status line flickering, debug log timestamps showing contextLimit dropping from 1M to 300K, and a deterministic 400 error killing the session. The before/after table in the PR body shows exactly which requests are affected. No question about whether the problem exists.

Direction: clearly aligned. A background subagent's AsyncLocalStorage frame leaking into the main session's model resolution is a correctness bug in the agent runtime — squarely within core scope. The mechanism (React batching a subagent's progress setState with the notification trigger, causing the drain effect to execute on the wrong ALS stack) is well-explained and matches the observed symptoms.

Size: 119 production lines (useGeminiStream.ts +87/-36 indented wrap, background-tasks.ts +13/-1, agent-context.ts +17, index.ts +1), 56 test lines. Well within bounds for a focused fix.

Approach: the two-layer defense is the right call. The consumer-side guard (runOutsideAgentContext wrapping the drain effect in useGeminiStream) closes the entire class — regardless of which producer's setState triggers the React commit. The producer-side defense (wrapping notificationCallback in emitNotification) adds belt-and-suspenders. The helper is minimal (storage.exit(fn) — one line, using the standard Node.js AsyncLocalStorage API), well-documented, and exported for future reuse. No scope creep, no unrelated changes.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必填章节都有实质内容,包括中文翻译。

问题:这是一个真实、已观测到的 bug。Issue #7156(P1)用详细证据记录了模型泄漏——状态栏闪烁、debug 日志时间戳显示 contextLimit 从 1M 降至 300K、确定性 400 错误导致会话死亡。PR 正文中的 before/after 表格精确标注了哪些请求受影响。问题存在性无疑。

方向:完全对齐。后台 subagent 的 AsyncLocalStorage frame 泄漏到主会话的模型解析,是 agent 运行时的正确性 bug——属于核心模块范畴。机制解释(React 将 subagent 的进度 setState 与通知触发合并到同一次 commit,导致 drain effect 在错误的 ALS 栈上执行)与观测到的症状吻合。

规模:119 行生产代码(useGeminiStream.ts +87/-36 缩进包裹、background-tasks.ts +13/-1、agent-context.ts +17、index.ts +1),56 行测试。在聚焦修复的合理范围内。

方案:两层防线的选择正确。消费端防线(runOutsideAgentContext 包裹 useGeminiStream 中的 drain effect)关闭整类问题——无论哪个 producer 的 setState 触发了 React commit。生产端防线(包裹 emitNotification 中的 notificationCallback)增加双保险。helper 极简(storage.exit(fn)——一行代码,使用标准 Node.js AsyncLocalStorage API),文档完善,已导出供后续复用。无范围蔓延,无无关改动。

进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is clean and well-targeted. A few observations:

The new runOutsideAgentContext helper is a one-line wrapper around AsyncLocalStorage.exit() — the standard Node.js API for running a callback outside the current ALS frame. It's the right primitive for this problem, and the JSDoc clearly explains why it exists and when to use it.

The consumer-side guard in useGeminiStream.ts wraps the entire notification drain effect body. This is the decisive fix: regardless of which producer's setState triggered the React commit, the drain and every async continuation it starts will resolve Config.getModel() to the main session's model. The producer-side guard in emitNotification is belt-and-suspenders — the callback now runs with no agent frame on the stack, so even if a future code path somehow bypasses the consumer guard, the notification itself is safe.

The notificationCallback! non-null assertion at line 1441 is safe — there's an early return at line 1379 (if (!this.notificationCallback) return) that guarantees the callback is set before reaching the guarded call.

The regression test (notification emission and agent context (#7156)) is well-designed: it sets up an agent frame, calls registry.complete() from inside it, and verifies the callback runs with getCurrentAgentId() === null and getRuntimeContentGenerator() === undefined. This test would fail on the unpatched registry — exactly what a regression test should do.

No critical issues found. No AGENTS.md violations.

Unit Tests

  • background-tasks.test.ts: 111/111 passed ✅
  • useGeminiStream.test.tsx: 169/169 passed ✅
  • npm run typecheck: clean ✅
  • npm run build: clean ✅

Real-Scenario Testing

Before (installed build)

$ qwen -p 'say hello in one sentence'
Hello! How can I help you today?

After (this PR)

$ node dist/cli.js -p 'say hello in one sentence'
Hello! I'm Qwen Code, ready to help with your software engineering tasks.

Background agent notification path (this PR)

$ node dist/cli.js -p 'launch a background agent to count from 1 to 5, then tell me the result when it finishes'
The agent finished. Here's the result:

1
2
3
4
5

The background agent launched, completed, the notification drained successfully, and the main session responded with the correct result. No model confusion, no errors. Note: full reproduction of #7156 requires two different model endpoints (large-context main + small-context subagent), which isn't available in this CI environment — the smoke test exercises the notification drain path but cannot verify the model-switching aspect directly. The unit regression test covers that deterministically.

中文说明

代码审查

实现简洁、目标明确。几点观察:

新的 runOutsideAgentContextAsyncLocalStorage.exit() 的一行封装——Node.js 标准 API,用于在当前 ALS frame 之外运行回调。对于这个问题来说是正确的原语,JSDoc 清楚解释了存在原因和使用场景。

useGeminiStream.ts 中的消费端防线包裹了整个通知 drain effect。这是决定性的修复:无论哪个 producer 的 setState 触发了 React commit,drain 及其启动的所有异步延续都会将 Config.getModel() 解析为主会话的模型。emitNotification 中的生产端防线是双保险——回调现在在无 agent frame 的栈上运行,即使未来某条代码路径绕过了消费端防线,通知本身也是安全的。

notificationCallback! 非空断言(第 1441 行)是安全的——第 1379 行有提前返回(if (!this.notificationCallback) return),保证到达受保护调用时回调已设置。

回归测试设计良好:在 agent frame 内设置、调用 registry.complete()、验证回调运行时 getCurrentAgentId() === nullgetRuntimeContentGenerator() === undefined。在未修复的 registry 上会失败——正是回归测试应有的行为。

未发现关键问题。无 AGENTS.md 违规。

单元测试

  • background-tasks.test.ts: 111/111 通过 ✅
  • useGeminiStream.test.tsx: 169/169 通过 ✅
  • npm run typecheck: 通过 ✅
  • npm run build: 通过 ✅

真实场景测试

基础 CLI 功能和后台 agent 通知路径均正常工作。后台 agent 启动、完成、通知汇入成功、主会话正确返回结果,无报错。注意:完整复现 #7156 需要两个不同的模型端点(大上下文主会话 + 小上下文 subagent),当前 CI 环境不具备此条件。单测回归测试已确定性覆盖该场景。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; a well-diagnosed bug with a minimal, correct fix.

This is a textbook example of how to fix an AsyncLocalStorage scoping bug. The problem is real (P1 issue #7156 with detailed debug log evidence), the root cause analysis is precise (React batching a subagent's progress setState with the notification trigger, causing the drain effect to inherit the wrong ALS frame), and the fix is exactly what the problem calls for — storage.exit() wrapping the two points where main-session-owned work can execute inside an agent frame.

The two-layer defense is the right architectural choice. The consumer guard closes the entire class (any producer's setState triggering the commit), and the producer guard adds defense-in-depth for the notification callback specifically. Neither layer is redundant — they protect against different failure modes.

119 production lines, 56 test lines, 5 files, zero scope creep. The regression test is deterministic and would catch the bug on the unpatched code. Everything builds, typechecks, and tests clean. The background agent smoke test confirms the notification drain path works without errors.

Ship it. ✅

中文说明

置信度: 5/5 — 各阶段均通过;诊断准确的 bug,修复简洁正确。

这是 AsyncLocalStorage 作用域 bug 修复的范例。问题真实存在(P1 issue #7156,附详细 debug 日志证据),根因分析精确(React 将 subagent 的进度 setState 与通知触发合并到同一次 commit,导致 drain effect 继承了错误的 ALS frame),修复恰好对应问题所需——storage.exit() 包裹主会话所属工作可能在 agent frame 内执行的两个点。

两层防线的架构选择正确。消费端防线关闭整类问题(任何 producer 的 setState 触发 commit),生产端防线为通知回调增加纵深防御。两层各有用途,保护不同的失败模式。

119 行生产代码、56 行测试、5 个文件、零范围蔓延。回归测试具有确定性,能在未修复代码上捕获该 bug。构建、类型检查、测试全部通过。后台 agent 冒烟测试确认通知路径无报错。

可以合入。✅

Qwen Code · qwen3.7-max

Reviewed at cc0e9c4e82d1763a44dd79f6c23be45cae7beb40 · 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. ✅

@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.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +3810 to +3812
runOutsideAgentContext(() => {
const queue = notificationQueueRef.current;
const targetType = queue[0]!.sendMessageType;

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] The consumer-side runOutsideAgentContext guard — described in the PR as the "decisive" fix for #7156 — has no targeted unit regression test. The existing notification test in useGeminiStream.test.tsx (line 6388, regression for #7114) invokes the drain without any agent ALS frame, so storage.exit(fn) is a transparent no-op in that test. Removing this wrapping would not change that test's outcome.

Failure scenario: a future refactor that removes or restructures the runOutsideAgentContext wrapping around the drain effect would silently re-introduce the model leak — notification turns resolve to the subagent's model, causing 400 errors on smaller-context models. No automated test would catch this regression.

The producer-side guard in background-tasks.ts has a well-constructed regression test that proves the mechanism. A similar test here — invoking the drain from inside runWithAgentContext/runWithRuntimeContentGenerator and asserting the drained submitQuery uses the main session's model — would close the gap.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 5a1a6aa: drains a notification outside a background agent ALS frame (useGeminiStream.test.tsx). It drives the notification callback with the whole act() flush inside runWithRuntimeContentGenerator — mirroring the contaminated React commit — bypassing the producer-side guard, and asserts the drained sendMessageStream call observes no runtime view. Verified it fails when the runOutsideAgentContext wrapping around the drain effect is removed (captured view = the subagent's). The branch is also rebased onto latest main: the earlier Test-job failure was the NOTICES.txt drift guard from #7161 tripping on this branch's pre-#7161 base, unrelated to the changed files.

中文:已在 5a1a6aa 补上该回归测试——整个 act() flush 在 runWithRuntimeContentGenerator 内执行以复刻被污染的 React commit(绕过生产端防线),断言汇入的 sendMessageStream 观察不到 runtime view;移除 drain effect 的 runOutsideAgentContext 包裹后该测试确实失败。分支已 rebase 到最新 main:此前 Test job 失败是 #7161 引入的 NOTICES.txt 漂移守卫在旧基线上触发,与本 PR 改动无关。

zjunothing and others added 2 commits July 19, 2026 11:54
…ALS frame

A background subagent running on its own model leaked that model into
the main session: the notification turn (and every turn after it) went
out on the subagent's model, overflowing its smaller context window
with a 400 — the same symptom QwenLM#7119 fixed on a different path (QwenLM#7156).

Mechanism, confirmed with a deterministic E2E reproducer: progress
setState calls issued from inside the subagent's AsyncLocalStorage
frame can be batched by React/Ink into the same commit as the
notification trigger. The drain effect then executes on that batch's
synchronous stack, and every async continuation submitQuery starts —
including Config.getModel() at send time — inherits the subagent's
runtime view. The "persistent" model switch reported in the issue is
each subsequent turn deriving from the contaminated drain chain.

Two layers:

- Consumer guard (the decisive fix): the notification drain effect in
  useGeminiStream runs its body inside runOutsideAgentContext(), a new
  agent-context helper wrapping AsyncLocalStorage.exit — the drained
  turn always runs on the main session's configuration regardless of
  which producer's setState triggered the commit. The E2E reproducer
  (session on big-context, custom agent on small-default,
  run_in_background) flips from the notification turn and all
  subsequent calls going to small-default, to everything staying on
  big-context.

- Producer defense: BackgroundTaskRegistry.emitNotification invokes
  the notification callback outside any agent frame, with a unit
  regression test that fails on the unpatched registry.

Thanks to @Aleks-0 for the instrumented traces on QwenLM#7156 that ruled out
persistent ModelsConfig mutation and pinned the ALS mechanism.

Fixes QwenLM#7156

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up on QwenLM#7194: the consumer-side guard had no targeted unit
regression test — the existing notification drain test runs with no
agent ALS frame active, so removing the wrapping would not change its
outcome.

The new test drives the notification callback with the whole act()
flush inside runWithRuntimeContentGenerator, mirroring the contaminated
React commit from QwenLM#7156, and asserts the drained sendMessageStream call
observes no runtime view. Verified to fail when the
runOutsideAgentContext wrapping around the drain effect is removed.

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

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@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.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Local build + real-run verification — ✅ recommend merge

I built this PR from source and verified it locally on Linux with three layers of evidence: the full touched unit suites, a real compiled harness (no mocks) driving the actual Config.getModel() / BackgroundTaskRegistry / ALS code paths, and a RED/GREEN negative control. Everything the fix claims holds up.

1. Touched unit suites — green

  • packages/core · background-tasks.test.ts111 passed / 111 (incl. the new notification emission and agent context (#7156) describe)
  • packages/cli · useGeminiStream.test.tsx152 passed / 152 (incl. the new drains a notification outside a background agent ALS frame)

2. Real compiled harness (packages/core/dist, no mocks)

I drove the real compiled Config.getModel(), the real BackgroundTaskRegistry.complete() → emitNotification, and the real ALS helpers — session model big-context, subagent model small-default. This walks the exact resolution path getModel() → getContentGeneratorConfig() → getRuntimeContentGenerator()?.contentGeneratorConfig ?? this.contentGeneratorConfig.

real compiled harness A/B

What the harness pins down:

  • Row 3 is the root cause — AsyncLocalStorage context follows every async continuation: a microtask/timer scheduled inside the subagent frame still resolves getModel() to small-default. That is why guarding a single producer is whack-a-mole.
  • Rows 4–5runOutsideAgentContext severs the frame both synchronously and through the async continuation the drained turn actually starts, which is the property the consumer-side drain guard relies on.
  • Row 6 — the real producer path: registry.complete() fired from inside the subagent frame now runs the callback (and any continuation it starts) with no frame, so the notification turn resolves to big-context.

On the fixed build 11/11 checks pass. With runOutsideAgentContext neutered to a passthrough (return fn() — the pre-fix "no frame exit"), rows 4/5/6 fail: the notification turn resolves to small-default both synchronously and in the async continuation — i.e. #7156 exactly, including the "persistent switch" (row 6c). So the harness discriminates the fix; it is not green-on-green.

Full 11-check output (both builds)
BUILD_TAG=PR #7194 (fixed)
PASS | 1. baseline getModel() (no frame)                                            expected "big-context"   actual "big-context"
PASS | 2. inside frame: agentId                                                     expected "bg-1"          actual "bg-1"
PASS | 2. inside frame: getModel() (subagent turn, correct)                         expected "small-default" actual "small-default"
PASS | 3. async continuation inside frame still sees subagent model (leak vector)   expected "small-default" actual "small-default"
PASS | 4. runOutsideAgentContext: getModel()                                        expected "big-context"   actual "big-context"
PASS | 4. runOutsideAgentContext: getCurrentAgentId()                               expected null            actual null
PASS | 5. runOutsideAgentContext: async continuation resolves main model            expected "big-context"   actual "big-context"
PASS | 6. producer: callback agentId                                                expected null            actual null
PASS | 6. producer: callback runtimeView undefined                                  expected true            actual true
PASS | 6. producer: callback getModel() (sync)                                      expected "big-context"   actual "big-context"
PASS | 6. producer: callback getModel() (async continuation)                        expected "big-context"   actual "big-context"
ALL_PASS=true

BUILD_TAG=Neutered guard (leak reproduced)
... rows 1-3 PASS ...
FAIL | 4. runOutsideAgentContext: getModel()                             expected "big-context" actual "small-default"
FAIL | 4. runOutsideAgentContext: getCurrentAgentId()                    expected null          actual "bg-1"
FAIL | 5. runOutsideAgentContext: async continuation resolves main model expected "big-context" actual "small-default"
FAIL | 6. producer: callback agentId                                     expected null          actual "bg-1"
FAIL | 6. producer: callback runtimeView undefined                       expected true          actual false
FAIL | 6. producer: callback getModel() (sync)                           expected "big-context" actual "small-default"
FAIL | 6. producer: callback getModel() (async continuation)             expected "big-context" actual "small-default"
ALL_PASS=false

3. RED / GREEN negative control

With the guard neutered but the PR's new tests kept, both regression tests flip RED with the precise assertions they were designed around (expected 'bg-1' to be null; expected { contentGenerator: {}, … } to be undefined); git checkout + rebuild returns them to green.

unit suites + RED/GREEN

Notes for the record

  • useGeminiStream.test.tsx reports 152 tests on my checkout vs the PR body's 169 — not a concern: the whole file is green with the new test present (likely a count taken at a different point / different local set).
  • I verified the mechanism end-to-end — the Config.getModel() resolution, the ALS async-continuation propagation, and the real producer path — rather than re-running the PR's full PTY-drives-React E2E. The consumer-side drain guard is covered by the cli unit RED/GREEN above.
  • Working tree left clean (neuter → git checkout → rebuild; harness re-passes 11/11).

Verdict: the root cause is correctly identified (ALS frame following async continuations into the drain), the consumer guard closes the whole class rather than one producer, and both the fix and its tests are proven discriminating. LGTM to merge.

Environment: Linux · Node v22.22.2 · vitest 3.2.4 · esbuild packages/core/dist.

中文说明

本地构建 + 真实运行验证 — ✅ 建议合并

我从源码构建了本 PR,并在 Linux 上用三层证据做了本地验证:完整的受影响单测套件、一个真实编译产物 harness(无 mock,直接驱动真正的 Config.getModel() / BackgroundTaskRegistry / ALS 代码路径),以及一个 RED/GREEN 反向对照。修复所声称的每一点都成立。

1. 受影响单测套件 — 全绿

  • packages/core · background-tasks.test.ts111 通过 / 111(含新增 notification emission and agent context (#7156) describe)
  • packages/cli · useGeminiStream.test.tsx152 通过 / 152(含新增 drains a notification outside a background agent ALS frame

2. 真实编译产物 harness(packages/core/dist,无 mock)

我驱动了真实编译的 Config.getModel()真实BackgroundTaskRegistry.complete() → emitNotification,以及真实的 ALS helper——会话模型 big-context、子 agent 模型 small-default,完整走通解析路径 getModel() → getContentGeneratorConfig() → getRuntimeContentGenerator()?.contentGeneratorConfig ?? this.contentGeneratorConfig。(见上方第一张截图)

harness 锁定的关键点:

  • 第 3 行是根因——AsyncLocalStorage 上下文会跟随每一个异步延续:在子 agent frame 内调度的 microtask/timer 仍把 getModel() 解析成 small-default。这正是「只防单个 producer 属于打地鼠」的原因。
  • 第 4–5 行——runOutsideAgentContext 无论是同步、还是透过被 drain 的轮次真正启动的异步延续,都切断了该 frame;这正是消费端 drain 防线所依赖的性质。
  • 第 6 行——真实生产端路径:在子 agent frame 内触发的 registry.complete(),现在以 frame 调用回调(及其启动的任何延续),因此通知轮次解析为 big-context

修复版构建 11/11 全部通过。把 runOutsideAgentContext 改成透传(return fn()——即修复前「不退出 frame」)后,第 4/5/6 行全部失败:通知轮次同步与异步延续都解析成 small-default——正是 #7156,包括「持久切换」(第 6c 行)。因此 harness 能区分修复,而非「绿对绿」的空验证。(完整 11 项输出见英文版折叠块。)

3. RED / GREEN 反向对照

在中和防线、保留 PR 新增测试的情况下,两个回归测试都按其设计的断言翻红(expected 'bg-1' to be nullexpected { contentGenerator: {}, … } to be undefined);git checkout + 重建后恢复全绿。(见上方第二张截图)

备注

  • 我的 checkout 上 useGeminiStream.test.tsx 报告 152 个测试,PR 描述写的是 169——不影响结论:整文件全绿且新测试在内(大概率是不同时间点/不同本地集合的计数差异)。
  • 我验证的是机制层面的端到端(Config.getModel() 解析 + ALS 异步延续传播 + 真实生产端路径),未重跑 PR 那套完整的 PTY 驱动 React E2E。消费端 drain 防线由上面的 cli 单测 RED/GREEN 覆盖。
  • 工作树保持干净(中和 → git checkout → 重建;harness 重新 11/11 通过)。

结论: 根因定位正确(ALS frame 跟随异步延续进入 drain),消费端防线关闭的是整类问题而非单个 producer,且修复与其测试都被证明具有区分度。LGTM,建议合并。

环境:Linux · Node v22.22.2 · vitest 3.2.4 · esbuild packages/core/dist


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.20.1.

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.

Bug: Subagent mutates main session model — context overflow recurrence after #7119

3 participants