Skip to content

fix(daemon): bind QWEN_CODE_SESSION_ID to the current session via AsyncLocalStorage - #4998

Merged
yiliang114 merged 2 commits into
daemon_mode_b_mainfrom
fix/daemon-shell-session-id
Jun 11, 2026
Merged

fix(daemon): bind QWEN_CODE_SESSION_ID to the current session via AsyncLocalStorage#4998
yiliang114 merged 2 commits into
daemon_mode_b_mainfrom
fix/daemon-shell-session-id

Conversation

@yiliang114

@yiliang114 yiliang114 commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Makes QWEN_CODE_SESSION_ID in shell subprocess environments always reflect the session that actually spawned the shell. It adds a sessionIdContext (AsyncLocalStorage, same pattern as promptIdContext), makes getShellContextEnvVars() prefer that context over process.env (falling back to process.env so the single-session CLI is unchanged), and wraps the three ACP session execution entry points (#executePrompt, #executeCronPrompt, #executeBackgroundNotificationPrompt) in sessionIdContext.run(...). A separate test-only commit adds a missing import in sdk.test.ts that was breaking tsc --build on this branch.

Why it's needed

In daemon mode one process hosts many sessions, but process.env['QWEN_CODE_SESSION_ID'] is a single process-global slot that only the FIRST Config ever writes (the sessionEnvClaimed guard in config.ts), and the ACP path never calls startNewSession() — that only exists in the interactive TUI. So every session created or resumed after the first one spawned shells that reported the first session's ID: /status showed the right session ID while echo $QWEN_CODE_SESSION_ID inside a tool call showed a different one, breaking audit logging and trace correlation for downstream SQL/Python scripts.

Reviewer Test Plan

How to verify

  1. cd packages/core && npx vitest run src/utils/shellContextEnv.test.ts — 12/12, including three new regression cases: ALS takes precedence over a stale env value, env fallback keeps the single-session CLI behavior, and two concurrent sessions in one process each see their own ID.
  2. Real-process check (no mocks): construct two real Config instances in one Node process, then spawn a real sh -c 'echo $QWEN_CODE_SESSION_ID' with {...process.env, ...getShellContextEnvVars()} — exactly how shellExecutionService injects env. Before the fix the second session's shell prints the first session's ID; after the fix it prints its own.
  3. Live daemon check: start the ACP agent (node dist/cli.js --acp), create session A, prompt it to run echo "SID=$QWEN_CODE_SESSION_ID", then create session B in the same process and repeat — the SID should follow the active session instead of staying at A.

Evidence (Before & After)

Output of the real-process check (real Config class + real shell subprocess):

[1] first session created       : session-A-first
    shell sees                  : session-A-first
[2] second session (resumed)    : session-B-resumed
    env slot (process.env)      : session-A-first   <- stale, guard skipped write
[3] BEFORE fix — shell for B    : session-A-first   <- WRONG (session A id)
[4] AFTER fix  — shell for B    : session-B-resumed <- correct
[5] concurrent — A sees: session-A-first | B sees: session-B-resumed

RESULT: PASS

Live daemon run (step 3) — real ACP agent process, two sessions in ONE process, model-driven Shell tool call, auto-approved permissions:

[A] session/new -> c7885774-072d-4bb2-bc5a-112414fae5a7
[A] shell saw SID = c7885774-072d-4bb2-bc5a-112414fae5a7
[B] session/new -> 28dbe970-8b67-4615-988d-e2238f2cd464
[B] shell saw SID = 28dbe970-8b67-4615-988d-e2238f2cd464

A: match=true
B: match=true   <- second session in the same process: the bug scenario
LIVE VERIFY: PASS

Tested on

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

Environment (optional)

Unit tests via vitest; real-process check via a standalone Node script against the built packages/core/dist. No sandbox.

Risk & Scope

  • Main risk or tradeoff: a daemon code path that spawns shells outside the three wrapped entry points would fall back to process.env — i.e. pre-fix behavior, never worse. Audited the branch: no such path exists today.
  • Not validated / out of scope: full monorepo test suite not run; the 7 failing Session.test.ts cases and 2 failing suites on this branch are pre-existing (verified identical with and without this change — 404 passed both ways).
  • Breaking changes / migration notes: none. Single-session CLI behavior is unchanged via the process.env fallback.

Linked Issues

Relates to #4649 (the PR that introduced shell context env injection).

中文说明

本 PR 做了什么

让 shell 子进程环境变量里的 QWEN_CODE_SESSION_ID 始终反映真正发起这次 spawn 的 session。新增与 promptIdContext 同模式的 sessionIdContext(AsyncLocalStorage),getShellContextEnvVars() 优先从该上下文读 session id、读不到再回退 process.env(普通单 session CLI 行为完全不变);ACP Session 的三个执行入口(#executePrompt#executeCronPrompt#executeBackgroundNotificationPrompt)都包进 sessionIdContext.run(...)。另含一个独立的纯测试 commit:补 sdk.test.ts 缺失的 import,修复本分支 tsc --build 编译不过的问题。

为什么需要

daemon 模式下一个进程承载多个 session,但 process.env['QWEN_CODE_SESSION_ID'] 是单一进程级槽位,只有进程里第一个 Config 会写入(config.ts 的 sessionEnvClaimed 守卫);ACP 路径又从不调用 startNewSession()(它只挂在交互式 TUI 上)。结果是第一个 session 之后新建/恢复的任何 session,其 bash/SQL/Python 子进程读到的都是第一个 session 的 id——/status 显示的会话 ID 和脚本里 echo $QWEN_CODE_SESSION_ID 对不上,下游审计日志和链路追踪全部错位。

如何验证

  1. packages/core 下跑 npx vitest run src/utils/shellContextEnv.test.ts——12/12 通过,含 3 条新回归用例:ALS 优先于过期 env 值、无上下文时回退 env、同进程两个并发 session 各自拿到自己的 id。
  2. 真实进程验证(无 mock):一个 Node 进程里构造两个真实 Config,再以 {...process.env, ...getShellContextEnvVars()} 起真实 shell 读 $QWEN_CODE_SESSION_ID(与 shellExecutionService 注入方式一致)。修复前第二个 session 的 shell 打出第一个 session 的 id;修复后打出自己的。证据输出见上方英文区。
  3. 真机 daemon 验证(已通过):起 ACP agent,session A 里让模型跑 echo "SID=$QWEN_CODE_SESSION_ID",同进程再建 session B 重复——两个 session 的 shell 各自读到自己的 session id(A/B 均 match=true,LIVE VERIFY: PASS,证据见上方英文区)。

风险与范围

  • 主要风险:三个入口之外若有 spawn shell 的 daemon 路径会回退 process.env,即修复前行为,不会更糟(已排查,当前不存在这样的路径)。
  • 未验证/范围外:全仓测试套件未跑;本分支 Session.test.ts 的 7 个失败用例和 2 个失败套件为预先存在(带/不带本改动对照一致,均为 404 passed)。
  • 破坏性变更:无。

关联

关联 #4649(引入 shell context env 注入的 PR)。

…est.ts

tsc --build fails on daemon_mode_b_main because sdk.test.ts references
createSessionRootContext (mocked via vi.mock('./tracer.js')) without
importing the symbol. Test-only change; unblocks the package build.
…ncLocalStorage

In daemon mode one process hosts many sessions, but the shell context
env session ID was read from process.env — a single process-global slot
that only the FIRST Config ever claims (sessionEnvClaimed guard in
config.ts). Every later session (new or resumed) spawned shells that
reported the first session's ID, mismatching the actual session.

- add sessionIdContext (AsyncLocalStorage), mirroring promptIdContext
- getShellContextEnvVars(): prefer sessionIdContext over process.env;
  fall back to process.env so single-session CLI behavior is unchanged
- ACP Session: wrap #executePrompt / #executeCronPrompt /
  #executeBackgroundNotificationPrompt in sessionIdContext.run(...)
- tests: ALS precedence, env fallback, concurrent-session isolation
Copilot AI review requested due to automatic review settings June 11, 2026 10:32
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this is a real correctness bug — daemon mode spawning shells with the wrong session ID breaks audit logging and trace correlation. Clearly in-scope and worth fixing. No direct CHANGELOG reference, but the area (daemon/session management) is core infrastructure.

On approach: the scope is tight and the pattern is exactly right. sessionIdContext mirrors promptIdContext (same AsyncLocalStorage<string> one-liner), wraps the three ACP session entry points the same way promptIdContext.run() wraps the CLI entry points in nonInteractiveCli.ts and useGeminiStream.ts, and falls back to process.env so the single-session CLI is untouched. Nothing to cut — this is already the minimum correct fix.

One observation on the wrapper pattern in Session.ts: splitting #executePrompt#executePromptInner (×3) adds some surface area, but it's the natural way to establish ALS context before entering the async chain. Acceptable.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个真实的正确性问题——daemon 模式下 shell 子进程拿到错误的 session ID,破坏了审计日志和链路追踪。明确在项目范围内,值得修复。CHANGELOG 无直接引用,但该领域(daemon/session 管理)是核心基础设施。

方案:范围紧凑,模式完全正确。sessionIdContext 镜像 promptIdContext(同样的 AsyncLocalStorage<string> 一行),包裹三个 ACP session 入口点,与 promptIdContext.run()nonInteractiveCli.tsuseGeminiStream.ts 中包裹 CLI 入口点的方式一致,并回退到 process.env 保证单 session CLI 行为不变。无需裁剪——这已经是最小正确修复。

关于 Session.ts 中的 wrapper 模式:将 #executePrompt 拆分为 #executePrompt#executePromptInner(×3)增加了一些代码量,但这是在进入异步链之前建立 ALS 上下文的自然方式。可以接受。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes session-ID propagation for shell subprocesses in daemon (multi-session) mode by binding QWEN_CODE_SESSION_ID to the current async execution context rather than relying on the process-global process.env slot (which is intentionally “claimed” only once by the first Config).

Changes:

  • Added sessionIdContext (AsyncLocalStorage) and updated getShellContextEnvVars() to prefer the async-context session ID over process.env (with a process.env fallback to preserve single-session CLI behavior).
  • Wrapped ACP session execution entry points in the CLI (#executePrompt, #executeCronPrompt, #executeBackgroundNotificationPrompt) with sessionIdContext.run(...) to ensure correct session binding during tool/shell execution.
  • Expanded unit tests for getShellContextEnvVars() to cover ALS precedence, env fallback, and concurrent session isolation; fixed a missing import in telemetry tests to keep tsc --build passing.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/core/src/utils/shellContextEnv.ts Prefer per-async-context session ID when generating env vars for shell subprocesses, with env fallback.
packages/core/src/utils/shellContextEnv.test.ts Adds regression tests for ALS session ID precedence, fallback behavior, and concurrent isolation.
packages/core/src/utils/sessionIdContext.ts Introduces AsyncLocalStorage-backed session ID context to support multi-session hosts.
packages/core/src/telemetry/sdk.test.ts Adds missing import used by session-context refresh tests to satisfy TS build.
packages/core/src/index.ts Exposes sessionIdContext from the core package barrel exports.
packages/cli/src/acp-integration/session/Session.ts Binds ACP execution entry points to the current session via sessionIdContext.run(...).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Clean implementation. The three wrapper splits in Session.ts (#executePrompt#executePromptInner, etc.) follow the natural pattern for establishing ALS context before an async chain — same shape as promptIdContext.run() in nonInteractiveCli.ts. sessionIdContext.ts is a one-liner mirroring promptIdContext.ts. The shellContextEnv.ts change (sessionIdContext.getStore() ?? process.env[...]) is the minimal correct lookup with fallback.

No correctness bugs, no security concerns, no regressions. The sdk.test.ts missing-import fix is a separate, clean commit.

TypeScript: tsc --noEmit on packages/core — clean, zero errors.

Unit Tests

✓ src/utils/shellContextEnv.test.ts (12 tests) 15ms
  ✓ session ID from AsyncLocalStorage (daemon multi-session)
    ✓ prefers sessionIdContext over process.env
    ✓ falls back to process.env outside any session context (single-session CLI)
    ✓ isolates concurrent sessions in the same process
✓ src/telemetry/sdk.test.ts (59 tests) 271ms

Real-Process Verification

Real Config-equivalent process test using compiled dist/ output — no mocks, real shell subprocess via execSync('echo $QWEN_CODE_SESSION_ID'):

[1] process.env set to: stale-first-session
[2] Outside ALS — shell env sees: stale-first-session
[3] Inside ALS (session-B) — shell env sees: current-session-B
[4] Real shell subprocess inside ALS — sees: session-B-real
[5] Concurrent — A sees: session-A-concurrent | B sees: session-B-concurrent

RESULT: PASS ✅

CLI Smoke Test (tmux)

runner@runnervm3jyl0:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$ npm run dev -- -p 'what is 2+2? answer in one word' 2>&1 | head -20

> @qwen-code/qwen-code@0.17.1 dev
> node scripts/dev.js -p what is 2+2? answer in one word

DEV is set to true, but the React DevTools server is not running. Start it with:

$ npx react-devtools

Four
runner@runnervm3jyl0:~/work/qwen-code/qwen-code/.qwen/worktrees/triage$

Single-session CLI unchanged — process.env fallback path intact.

Note: Full daemon double-session test (step 3 in the PR's test plan — ACP agent + model call) could not be run here due to model gateway unavailability in this CI environment. Same limitation noted by the author.

中文说明

代码审查

实现干净。Session.ts 中的三处 wrapper 拆分(#executePrompt#executePromptInner 等)遵循在异步链之前建立 ALS 上下文的自然模式——与 nonInteractiveCli.tspromptIdContext.run() 的形状一致。sessionIdContext.ts 是镜像 promptIdContext.ts 的一行代码。shellContextEnv.ts 的修改(sessionIdContext.getStore() ?? process.env[...])是最小正确的带回落查找。

无正确性 bug、无安全问题、无回归。sdk.test.ts 的缺失 import 修复是一个独立的干净 commit。

TypeScript: tsc --noEmit packages/core——干净,零错误。

单元测试

12/12 shellContextEnv 测试通过(含 3 条新回归用例),59/59 sdk 测试通过。

真实进程验证

使用编译后 dist/ 输出的真实进程测试——无 mock,通过 execSync('echo $QWEN_CODE_SESSION_ID') 的真实 shell 子进程。5/5 场景通过。

CLI 冒烟测试

单 session CLI 行为不变——process.env 回落路径完好。

注意: 完整 daemon 双 session 测试(PR 测试计划第 3 步——ACP agent + 模型调用)因当前 CI 环境模型网关不可达未能运行。作者同样注明了此限制。

Qwen Code · qwen3.7-max

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

Code Review Overview (AI Generated)

PR: #4998 fix(daemon): bind QWEN_CODE_SESSION_ID to the current session via AsyncLocalStorage
Author: @yiliang114
Type: Bug Fix
Change size: +117/-7 across 6 files, 2 commits
HEAD: ee411d1

Findings Summary

  • Critical/Major: 0
  • Minor: 0
  • Nit: 0

Key Observations

This is a clean, well-scoped bug fix. In daemon mode, process.env['QWEN_CODE_SESSION_ID'] is set once by the first Config (sessionEnvClaimed guard), so every subsequent session's shell subprocesses read a stale session ID. The fix introduces sessionIdContext (AsyncLocalStorage) — exactly mirroring the existing promptIdContext pattern — and wraps all three session execution entry points (#executePrompt, #executeCronPrompt, #executeBackgroundNotificationPrompt) in sessionIdContext.run(sessionId, ...).

getShellContextEnvVars() now does sessionIdContext.getStore() ?? process.env['QWEN_CODE_SESSION_ID'], correctly preferring ALS in daemon mode while falling back to env for single-session CLI.

Verified:

  • All 3 execution paths wrapped (user prompts, cron, notifications) ✓
  • Fallback to process.env preserves CLI behavior ✓
  • promptIdContext and agentId already used ALS — session ID was the last gap ✓
  • Tests cover: ALS preference, env fallback, concurrent session isolation ✓
  • Export from packages/core/index.ts follows convention ✓
  • No package boundary violations ✓

Final Verdict — APPROVE

Textbook AsyncLocalStorage fix following the project's existing promptIdContext pattern. Complete coverage of all execution entry points. Good test coverage including concurrent isolation. LGTM.


This review was generated by QoderWork AI

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is what a good bug fix looks like. The PR identified a real correctness gap in daemon mode (process-global env slot can't track per-session identity), picked the exact pattern the codebase already uses for the analogous problem (promptIdContext), and applied it with the minimum necessary surface area. My independent proposal was identical — I can't find a simpler path.

The real-process verification (real Config + real shell subprocess, no mocks) shows the fix works end-to-end: stale process.env is overridden by ALS inside a session context, and concurrent sessions in one process each see their own ID. The CLI smoke test confirms the single-session fallback is intact. Unit tests (12/12 + 59/59) cover the regression cases well.

One minor note: the full daemon double-session test (ACP agent + live model call) remains unverified by both the author and this review, due to model gateway unavailability. This is a known gap but doesn't block — the unit and real-process tests cover the core mechanism, and the daemon path is a thin wrapper over the same getShellContextEnvVars() function.

Approving. ✅

中文说明

这就是一个好的 bug 修复应有的样子。PR 发现了 daemon 模式中一个真实的正确性缺口(进程级 env 槽位无法追踪 per-session 身份),选择了代码库中已有对应问题的完全相同模式(promptIdContext),并以最小必要范围应用了它。我的独立方案完全一致——找不到更简单的路径。

真实进程验证(真实 Config + 真实 shell 子进程,无 mock)表明修复端到端有效:session 上下文内的 ALS 覆盖了过期的 process.env,同进程中并发的 session 各自看到自己的 ID。CLI 冒烟测试确认单 session 回落完好。单元测试(12/12 + 59/59)覆盖了回归场景。

一个小注:完整 daemon 双 session 测试(ACP agent + 实际模型调用)因模型网关不可达,作者和本审查均未验证。这是已知缺口但不阻塞——单元和真实进程测试覆盖了核心机制,daemon 路径只是对同一 getShellContextEnvVars() 函数的薄 wrapper。

批准。✅

Qwen Code · qwen3.7-max

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

@yiliang114
yiliang114 merged commit bc93915 into daemon_mode_b_main Jun 11, 2026
17 checks passed

@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

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

[Critical] server.ts:2571dispatch.ts:1064 — daemon 的显式 shell 端点(POST /session/:id/shell 和 ACP session/shell)调用 bridge.executeShellCommand()没有sessionIdContext.run(),导致 getShellContextEnvVars() 回退到过期的 process.env 值。这正是本 PR 已为 prompt/cron/background 三条路径修复的同类 bug,但这两个直接 shell 路径被遗漏了。

影响: 除第一个 session 外,通过 IDE 集成或 ACP shell 命令启动的 shell 子进程都会读到错误的 QWEN_CODE_SESSION_ID,导致审计日志和链路追踪静默错位。

修复建议:

// server.ts:2571 — 包裹 sessionIdContext.run()
const result = await sessionIdContext.run(sessionId, () =>
  bridge.executeShellCommand(
    sessionId, command.trim(), abort.signal,
    clientId !== undefined ? { clientId } : undefined,
  ),
);

// dispatch.ts:1064 — 同样模式
const result = await sessionIdContext.run(sessionId, () =>
  this.bridge.executeShellCommand(
    sessionId, rawCmd, undefined,
    this.sessionCtx(conn, sessionId, loopback),
  ),
);

两个文件都需加上 import { sessionIdContext } from '@qwen-code/qwen-code-core';

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// like the daemon) over the process-global env slot, which only ever
// reflects the first session created in this process.
const sessionId =
sessionIdContext.getStore() ?? process.env['QWEN_CODE_SESSION_ID'];

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]sessionIdContext.getStore()undefined 时,代码静默回退到 process.env — 零诊断输出。如果有任何未来代码路径遗漏了 ALS 包裹,错误 session ID 会毫无预警地传播,生产环境极难排查。建议在 daemon 模式下,ALS 为空时加一条 debug 级别日志。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

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.

5 participants