-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(telemetry): align session lifecycle with OpenTelemetry #8616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b06954f
e774bb6
5b9c8c6
716b243
babe9db
0588bf9
aba197f
8cbb1ed
8274e0c
cdfaea1
05859b8
946b27a
95e2b18
7e30622
2ac0d47
2b160e0
33916e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| # OpenTelemetry Session Lifecycle | ||
|
|
||
| ## Status | ||
|
|
||
| Implemented in issue #8589. | ||
|
|
||
| ## Scope | ||
|
|
||
| Qwen Code already records the application session ID as `session.id` and maps | ||
| it to `gen_ai.conversation.id` on GenAI LLM and agent spans. This design adds | ||
| the OpenTelemetry General Session lifecycle events without removing the | ||
| existing Qwen-specific telemetry fields or event names. | ||
|
|
||
| The implementation follows the Development-status General Session semantic | ||
| conventions at: | ||
|
|
||
| <https://opentelemetry.io/docs/specs/semconv/general/session/> | ||
|
|
||
| The GenAI conversation mapping follows: | ||
|
|
||
| <https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-agent-spans.md> | ||
|
|
||
| ## Event representation | ||
|
|
||
| The standard lifecycle events are emitted as OpenTelemetry LogRecords with | ||
| the required `event.name` attribute: | ||
|
|
||
| | Event | Required attributes | Emission point | | ||
| | --------------- | ------------------- | -------------------------------------------------------- | | ||
| | `session.start` | `session.id` | Initial `Config` initialization and every session switch | | ||
| | `session.end` | `session.id` | Session switch and telemetry shutdown | | ||
|
|
||
| The existing `qwen-code.config` / `cli_config` and RUM `session_start` events | ||
| remain unchanged for backward compatibility. The standard records are | ||
| additive and are emitted through the configured OpenTelemetry logs pipeline. | ||
|
|
||
| ## Session continuation | ||
|
|
||
| `Config.startNewSession()` is used for both replacing the current conversation | ||
| (`/clear`, `/new`) and resuming a persisted conversation. A persisted | ||
| `sessionData` argument identifies the latter continuation case. On a | ||
| continuation, the new `session.start` record includes | ||
| `session.previous_id`; replacement sessions do not claim continuation. | ||
|
|
||
| The outgoing session is ended before the new session starts. Resuming the | ||
| session the user is already in (same `session.id`) records no lifecycle | ||
| transition at all. Telemetry shutdown ends the currently active session | ||
| before shutting down the SDK. | ||
|
|
||
| ## Session id reuse on `/resume` | ||
|
|
||
| Qwen Code's session model predates this design: `/resume` restores a | ||
| persisted conversation under its original session id instead of minting a new | ||
| one. Two consequences follow for the lifecycle stream: | ||
|
|
||
| - A resumed id can carry more than one disjoint | ||
| `session.start`/`session.end` window within a single process (for example: | ||
| start `A`, `/clear` to `B`, then `/resume` back to `A`). | ||
| - `session.previous_id` points from the resumed id to the session that was | ||
| active at resume time. That session may have been created _after_ the | ||
| resumed id, so lineage edges can point backwards in time and can form | ||
| cycles. | ||
|
|
||
| This is the reverse of the OTel General Session convention's id-rotation | ||
| model, in which a freshly minted id points back at the retired one. Backends | ||
| counting sessions or computing durations should key on | ||
| (`session.id`, `session.start` timestamp) windows rather than `session.id` | ||
| alone. Whether `/resume` should mint a new id instead is a session-model | ||
| decision outside this design. | ||
|
|
||
| ## Known limitations (daemon / ACP) | ||
|
|
||
| Daemon-spawned ACP sessions build a fresh `Config` per session | ||
| (`loadCliConfig()`) and never flow through `Config.startNewSession()`, so in | ||
| that path today: | ||
|
|
||
| - a conversation session receives `session.start` from its `Config` | ||
| initialization but no `session.end` when the session is later switched or | ||
| disposed, and | ||
| - process shutdown ends the session id last recorded in the telemetry session | ||
| context — in an ACP child that is the boot-time session, not the | ||
| conversation session. | ||
|
|
||
| A single ACP child can also host several concurrent sessions, which the | ||
| single process-level "current session" tracked by the context cannot | ||
| represent. Closing this gap requires lifecycle design for multi-session | ||
| processes and is deferred to a follow-up. | ||
|
|
||
| ## Compatibility and safety | ||
|
|
||
| - `session.id` remains on existing spans and logs. | ||
| - `gen_ai.conversation.id` remains the session correlation field for GenAI | ||
| spans. | ||
| - `session.previous_id` is emitted only when the application has an explicit | ||
| persisted continuation, and it is never equal to the new `session.id`. | ||
| - Cold-start resumptions (`--resume`, `--continue`, `--fork-session`) do not | ||
| carry `session.previous_id`; startup lineage, including the fork source, is | ||
| left to a follow-up. | ||
| - Session event emission is best-effort through the existing OTel logger and | ||
| does not block session switching or shutdown. | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -562,6 +562,12 @@ The following events are logged: | |||||||||
| - `qwen-code.config`: Emitted once at startup with CLI configuration. | ||||||||||
| - **Attributes**: `model`, `sandbox_enabled`, `core_tools_enabled`, `approval_mode`, `file_filtering_respect_git_ignore`, `debug_mode`, `truncate_tool_output_threshold`, `truncate_tool_output_lines`, `hooks` (comma-separated, omitted if disabled), `ide_enabled`, `interactive_shell_enabled`, `mcp_servers`, `mcp_servers_count`, `mcp_tools`, `mcp_tools_count`, `output_format`, `skills`, `subagents` | ||||||||||
|
|
||||||||||
| - `session.start`: A session begins. Emitted after telemetry initialization at startup and again on every session switch; lifecycle semantics are described in the Spans section. | ||||||||||
| - **Attributes**: `session.id` (string), `session.previous_id` (string, present only when this start continues a persisted conversation under a new session id) | ||||||||||
|
Comment on lines
+565
to
+566
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This attribute description uses the OTel id-rotation model ("continues a persisted conversation under a new session id"), but the implementation's primary continuation flow does the reverse:
Suggested change
中文说明该属性描述套用了 OTel 的 id 轮换模型(“在新的 session id 下延续持久化会话”),但实现中的主要续接流程恰好相反: — qwen3.8-max via Qwen Code /review (v0.21.7) |
||||||||||
|
|
||||||||||
| - `session.end`: A session ends. Emitted before a session switch replaces the current session, and at telemetry shutdown. | ||||||||||
| - **Attributes**: `session.id` (string) | ||||||||||
|
|
||||||||||
| - `qwen-code.user_prompt`: User submits a prompt. | ||||||||||
| - **Attributes**: `prompt_length` (int), `prompt_id` (string), `prompt` (string, excluded if `log_prompts_enabled` is false), `auth_type` (string) | ||||||||||
|
|
||||||||||
|
|
@@ -858,6 +864,20 @@ The daemon process (long-running HTTP server mode) exposes its own metrics. | |||||||||
|
|
||||||||||
| Distributed tracing spans form a tree rooted at `qwen-code.interaction`. Each interaction is a trace root with its own `traceId`; cross-prompt correlation uses the `session.id` attribute. | ||||||||||
|
|
||||||||||
| Session lifecycle is also exported through the OpenTelemetry General Session | ||||||||||
| semantic conventions. When the OTel logs pipeline is enabled, Qwen Code emits | ||||||||||
| `session.start` and `session.end` log events with the required `session.id` | ||||||||||
|
Comment on lines
+867
to
+869
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The new LogRecord events are documented only as prose here in the Spans section, while every sibling log event has a bullet entry in the Logs catalog ("The following events are logged:" → "Core Session Events", ~line 556, where 中文说明新的 LogRecord 事件仅以散文形式记录在 Spans 章节,而 Logs 目录("The following events are logged:" → "Core Session Events",约 556 行)中每个同级日志事件都有条目。代价:按该目录构建仪表板的用户找不到 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||||||||||
| attribute (cataloged under Core Session Events above). A resumed persisted | ||||||||||
| conversation includes `session.previous_id` on its `session.start` event only | ||||||||||
| when the resumed session id differs from the current one; cold-start | ||||||||||
| resumptions (`--resume`, `--continue`, `--fork-session`) do not carry it. | ||||||||||
| `/clear` and other replacement flows intentionally do not claim continuation | ||||||||||
| because they discard the previous conversation. | ||||||||||
|
|
||||||||||
| The existing Qwen-specific `qwen-code.config`/`cli_config` and RUM | ||||||||||
| `session_start` records remain available for compatibility. GenAI request | ||||||||||
| spans continue to use `gen_ai.conversation.id` for the same owning session ID. | ||||||||||
|
|
||||||||||
| - `qwen-code.interaction`: Root span for each user prompt turn. | ||||||||||
| - **Attributes**: `session.id`, optional ARMS extension `gen_ai.user.id`, `qwen-code.prompt_id`, `qwen-code.message_type`, `qwen-code.model`, `qwen-code.approval_mode`, `interaction.sequence`, `interaction.duration_ms`, `qwen-code.turn_status` ("ok"/"error"/"cancelled") | ||||||||||
|
|
||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,8 @@ import { | |
| isTelemetrySdkInitialized, | ||
| shutdownTelemetry, | ||
| refreshSessionContext, | ||
| logStartSession, | ||
| logSessionEnd, | ||
| } from '../telemetry/index.js'; | ||
| import type { | ||
| ContentGenerator, | ||
|
|
@@ -326,6 +328,8 @@ vi.mock('../telemetry/loggers.js', async (importOriginal) => { | |
| return { | ||
| ...actual, | ||
| logRipgrepFallback: vi.fn(), | ||
| logStartSession: vi.fn(actual.logStartSession), | ||
| logSessionEnd: vi.fn(actual.logSessionEnd), | ||
| }; | ||
| }); | ||
|
|
||
|
|
@@ -2232,6 +2236,84 @@ describe('Server Config (config.ts)', () => { | |
| }); | ||
|
|
||
| describe('startNewSession', () => { | ||
| it('records no lifecycle transition when resuming the current session id', async () => { | ||
| const sessionId = 'same-session-id'; | ||
| const config = new Config({ ...baseParams, sessionId }); | ||
| await config.initialize({ | ||
| skipGeminiInitialization: true, | ||
| skipHooks: true, | ||
| skipMcpDiscovery: true, | ||
| skipSkillManager: true, | ||
| skipFileCheckpointing: true, | ||
| }); | ||
| vi.mocked(logSessionEnd).mockClear(); | ||
| vi.mocked(logStartSession).mockClear(); | ||
|
|
||
| config.startNewSession(sessionId, { | ||
| conversation: { messages: [] }, | ||
| } as unknown as ResumedSessionData); | ||
|
|
||
| expect(logSessionEnd).not.toHaveBeenCalled(); | ||
| expect(logStartSession).toHaveBeenCalledWith( | ||
| config, | ||
| expect.anything(), | ||
| undefined, | ||
| ); | ||
| }); | ||
|
|
||
| it('ends the outgoing session before starting a replacement without continuation', async () => { | ||
| const config = new Config({ ...baseParams }); | ||
| await config.initialize({ | ||
| skipGeminiInitialization: true, | ||
| skipHooks: true, | ||
| skipMcpDiscovery: true, | ||
| skipSkillManager: true, | ||
| skipFileCheckpointing: true, | ||
| }); | ||
| const outgoingSessionId = config.getSessionId(); | ||
| const endedSessionIds: string[] = []; | ||
| vi.mocked(logSessionEnd).mockClear(); | ||
| vi.mocked(logStartSession).mockClear(); | ||
| vi.mocked(logSessionEnd).mockImplementationOnce((cfg: Config) => { | ||
| endedSessionIds.push(cfg.getSessionId()); | ||
| }); | ||
|
|
||
| config.startNewSession('replacement-session'); | ||
|
|
||
| expect(endedSessionIds).toEqual([outgoingSessionId]); | ||
| expect(logStartSession).toHaveBeenCalledWith( | ||
| config, | ||
| expect.anything(), | ||
| undefined, | ||
| ); | ||
| expect(vi.mocked(logSessionEnd).mock.invocationCallOrder[0]).toBeLessThan( | ||
| vi.mocked(logStartSession).mock.invocationCallOrder[0], | ||
| ); | ||
| }); | ||
|
|
||
| it('carries the outgoing session id when resuming a different persisted session', async () => { | ||
| const config = new Config({ ...baseParams }); | ||
| await config.initialize({ | ||
| skipGeminiInitialization: true, | ||
| skipHooks: true, | ||
| skipMcpDiscovery: true, | ||
| skipSkillManager: true, | ||
| skipFileCheckpointing: true, | ||
| }); | ||
| const outgoingSessionId = config.getSessionId(); | ||
| vi.mocked(logStartSession).mockClear(); | ||
|
|
||
| config.startNewSession('resumed-session-id', { | ||
| conversation: { messages: [] }, | ||
| } as unknown as ResumedSessionData); | ||
|
Comment on lines
+2306
to
+2308
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R6-2: The resume-path test asserts only the const endedSessionIds: string[] = [];
vi.mocked(logSessionEnd).mockClear();
vi.mocked(logStartSession).mockClear();
vi.mocked(logSessionEnd).mockImplementationOnce((cfg: Config) => {
endedSessionIds.push(cfg.getSessionId());
});
config.startNewSession('resumed-session-id', {
conversation: { messages: [] },
} as unknown as ResumedSessionData);
expect(endedSessionIds).toEqual([outgoingSessionId]);
expect(vi.mocked(logSessionEnd).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(logStartSession).mock.invocationCallOrder[0],
);中文说明该 resume 路径测试只断言了 — qwen3.8-max via Qwen Code /review (v0.21.8) |
||
|
|
||
| expect(logStartSession).toHaveBeenCalledWith( | ||
| config, | ||
| expect.anything(), | ||
| outgoingSessionId, | ||
|
Comment on lines
+2311
to
+2313
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This resume-path test asserts only the 中文说明该 resume 路径测试只断言了 — qwen3.8-max via Qwen Code /review (v0.21.7) |
||
| ); | ||
| }); | ||
|
|
||
| it('rejects a session switch while the current recorder owns the writer lease', () => { | ||
| const config = new Config({ ...baseParams, chatRecording: true }); | ||
| const originalSessionId = config.getSessionId(); | ||
|
|
@@ -2248,6 +2330,9 @@ describe('Server Config (config.ts)', () => { | |
| } | ||
| ).chatRecordingService = recorder; | ||
|
|
||
| vi.mocked(logSessionEnd).mockClear(); | ||
| vi.mocked(logStartSession).mockClear(); | ||
|
|
||
| expect(() => config.startNewSession('replacement-session')).toThrow( | ||
| expect.objectContaining({ | ||
| name: 'SessionWriterUnavailableError', | ||
|
|
@@ -2258,6 +2343,9 @@ describe('Server Config (config.ts)', () => { | |
| expect(config.getChatRecordingService()).toBe(recorder); | ||
| expect(finalize).not.toHaveBeenCalled(); | ||
| expect(flush).not.toHaveBeenCalled(); | ||
| // A rejected switch must leave the live session's lifecycle untouched. | ||
| expect(logSessionEnd).not.toHaveBeenCalled(); | ||
| expect(logStartSession).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| const resumedGoalSession = ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -129,6 +129,7 @@ import { | |||||||||||||||||||||||||
| shutdownTelemetry, | ||||||||||||||||||||||||||
| refreshSessionContext, | ||||||||||||||||||||||||||
| logStartSession, | ||||||||||||||||||||||||||
| logSessionEnd, | ||||||||||||||||||||||||||
| logRipgrepFallback, | ||||||||||||||||||||||||||
|
Comment on lines
131
to
133
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Untested-wiring pattern (3/6 — this import): the efficacy probe reverted this hunk on its own and every test stayed green; no test in this diff exercises the Config-side wiring to 中文说明未测试的接线(模式 3/6——此导入):探针单独回退该代码块后所有测试仍然通过;本 diff 没有任何测试覆盖 Config 侧到 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||||||||||||||||||||||||||
| RipgrepFallbackEvent, | ||||||||||||||||||||||||||
| StartSessionEvent, | ||||||||||||||||||||||||||
|
|
@@ -3797,7 +3798,15 @@ export class Config { | |||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const previousSessionId = this.sessionId; | ||||||||||||||||||||||||||
| this.sessionId = sessionId ?? randomUUID(); | ||||||||||||||||||||||||||
| const nextSessionId = sessionId ?? randomUUID(); | ||||||||||||||||||||||||||
| // Resuming the session the user is already in keeps the same id. That is | ||||||||||||||||||||||||||
| // not a lifecycle transition: ending it here would record session.end for | ||||||||||||||||||||||||||
| // a live session and pair it with a duplicate session.start. | ||||||||||||||||||||||||||
| const isSessionTransition = nextSessionId !== previousSessionId; | ||||||||||||||||||||||||||
| if (isSessionTransition) { | ||||||||||||||||||||||||||
| logSessionEnd(this); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| this.sessionId = nextSessionId; | ||||||||||||||||||||||||||
| // Unconditional: startNewSession is only called on the canonical Config | ||||||||||||||||||||||||||
| // instance (the one that already claimed via sessionEnvClaimed), so this | ||||||||||||||||||||||||||
| // correctly updates the env var to reflect the new active session. | ||||||||||||||||||||||||||
|
|
@@ -3843,7 +3852,11 @@ export class Config { | |||||||||||||||||||||||||
| // one, and the "N-shotted" PR label would span sessions. | ||||||||||||||||||||||||||
| CommitAttributionService.resetInstance(); | ||||||||||||||||||||||||||
| if (this.initialized) { | ||||||||||||||||||||||||||
| logStartSession(this, new StartSessionEvent(this)); | ||||||||||||||||||||||||||
| logStartSession( | ||||||||||||||||||||||||||
| this, | ||||||||||||||||||||||||||
| new StartSessionEvent(this), | ||||||||||||||||||||||||||
| sessionData && isSessionTransition ? previousSessionId : undefined, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
Comment on lines
+3855
to
+3859
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Resuming the currently-active session emits
Suggested change
中文说明恢复当前正在使用的 session 时,会发出 — qwen3.8-max via Qwen Code /review (v0.21.6)
Comment on lines
+3855
to
+3859
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Untested-wiring pattern (2/6 — the continuation gate): the efficacy probe reverted this hunk on its own and every test stayed green — nothing verifies that 中文说明未测试的接线(模式 2/6——续接门控):探针单独回退该代码块后所有测试仍然通过——没有任何测试验证 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Refresh the runtime.json sidecar so external observers (terminal | ||||||||||||||||||||||||||
|
|
@@ -3859,7 +3872,7 @@ export class Config { | |||||||||||||||||||||||||
| // sidecar that happens to share the outgoing session id | ||||||||||||||||||||||||||
| // mirrors the kimi-cli "write only when a session is | ||||||||||||||||||||||||||
| // established for this process" rule. | ||||||||||||||||||||||||||
| if (this.runtimeStatusEnabled && previousSessionId !== this.sessionId) { | ||||||||||||||||||||||||||
| if (this.runtimeStatusEnabled && isSessionTransition) { | ||||||||||||||||||||||||||
| const oldPath = this.storage.getRuntimeStatusPath(previousSessionId); | ||||||||||||||||||||||||||
| const newPath = this.storage.getRuntimeStatusPath(this.sessionId); | ||||||||||||||||||||||||||
| const cliVersion = this.cliVersion ?? null; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ export { | |
| } from './config.js'; | ||
| export { | ||
| logStartSession, | ||
| logSessionEnd, | ||
| logUserPrompt, | ||
|
Comment on lines
34
to
36
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Untested-wiring pattern (4/6 — this barrel export): the efficacy probe reverted this hunk on its own and every test stayed green; no test exercises the public telemetry surface for 中文说明未测试的接线(模式 4/6——此 barrel 导出):探针单独回退该代码块后所有测试仍然通过;没有测试覆盖 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||
| logUserRetry, | ||
| logToolCall, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The PR closes #8589 but leaves two of its acceptance criteria unmet on the daemon/ACP path, and the follow-up this sentence defers to does not exist as a tracked issue (two GitHub searches found only #8589 itself). Verified in code: every ACP session teardown passes
shutdownTelemetry: false(acpAgent.ts), and the only productionlogSessionEndcall site isConfig.startNewSession(config.ts), which these sessions never flow through — so a daemon-served conversation session emitssession.startfromConfig.initialize()and is never ended when disposed. — Failure scenario: issue #8589's criteria require "a closed session produces an OTelsession.endevent with the samesession.id" and tests covering "close, multi-session attribution"; backends keying session windows on (session.id,session.start) — the pairing this design doc tells consumers to use — keep every daemon-served session open indefinitely, and merging "Closes #8589" drops this half with no tracked remainder. Fix: file a follow-up issue coveringsession.endon ACP/daemon per-session disposal and reference it here and in the PR description before merge, or obtain explicit maintainer sign-off on the reduced scope in the PR thread.中文说明
本 PR 声称关闭 #8589,但 daemon/ACP 路径上该 issue 的两条验收标准仍未满足,而本句所延后的后续工作并不存在对应的跟踪 issue(两次 GitHub 搜索只找到 #8589 本身)。已在代码中核实:ACP 每个会话的 teardown 都传
shutdownTelemetry: false(acpAgent.ts),而logSessionEnd唯一的生产调用点是Config.startNewSession(config.ts),这些会话从不经过该路径——因此 daemon 服务的会话会在Config.initialize()发出session.start,被销毁时却永远不发 end。触发场景:#8589 的验收标准要求“关闭的会话以相同session.id产生 OTelsession.end事件”、测试需覆盖“close、多会话归属”;按本设计文档告知消费者的 (session.id, session.start) 配对方式划定会话窗口的后端,会看到 daemon 服务的每个会话永远不关闭;合并“Closes #8589”将使这一半验收标准在无跟踪残留的情况下被丢弃。修复:在合并前创建一个覆盖 ACP/daemon 每会话销毁时补发session.end的后续 issue,并在此处与 PR 描述中引用;或在 PR 讨论中获得维护者对缩减范围的明确认可。— qwen3.8-max via Qwen Code /review (v0.21.7)