qwen-code 调用方指定会话 ID(Caller-Supplied Session ID)全解析:daemon 级 admission 契约与 REST/ACP/SDK 实践
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
本文深入剖析 qwen-code daemon 的"调用方指定会话 ID"(Caller-Supplied Session ID)能力:它允许客户端在创建会话前自主选定会话 ID,从而把会话身份与自身工作流状态原子化地持久化。文章从设计契约、UUID 校验规则、daemon 级冲突准入(admission)、REST/ACP 双传输行为、SDK/MCP 能力协商到错误契约逐一展开,并辅以仓库源码与单元测试佐证。读完你将掌握如何通过POST /session { sessionId }、ACPsession/new._meta["qwen-code/sessionId"]及 TypeScript/Java SDK 安全地指定会话 ID,理解冲突检测、运行时替换与跨工作区唯一性保障的底层原理。
一、背景:为什么需要"创建前就定好 ID"
daemon 的客户端有时需要在会话真正创建之前就确定会话 ID——这样它们可以把会话身份与自身工作流状态原子化地一起持久化。例如一个自动化流水线在记录"本次任务对应哪个会话"时,如果 ID 是创建响应里才返回的,那么"先写状态、后拿 ID"与"先拿 ID、后写状态"之间总存在竞态窗口。
在引入本设计之前,REST 实现虽然已经转发过可选 ID,但唯一性与恢复协调只局限在某一条路由、某一个工作区运行时内;ACP、各语言 SDK、运行时替换(runtime replacement)以及直接 stdio agent 入口观察到的行为各不相同。本设计把"调用方指定 ID"提升为daemon 全局统一契约,同时不改变核心会话格式、也不引入持久化的全局索引。
对应的设计文档见 docs/design/2026-08-01-caller-supplied-session-id.md,核心实现位于 packages/cli/src/serve/session-id-admission.ts。
二、契约:可选字段与严格的 UUID 校验
2.1 字段语义
- 调用方指定 ID 是可选的:
undefined与null表示"未提供 ID"。 - 提供的值必须是字符串形式的 RFC 变体 UUID v1–v5。
- daemon 会将其统一规范化为小写。
- 明确拒绝:nil UUID、不支持的版本号、非 RFC 变体、路径字符、Arena
-agent-*后缀、以及一切非字符串值。
2.2 内部验证器与调用方验证器的差异
内部会话 ID 验证器继续接受既有的 Arena 后缀(即-agent-*形式),而调用方验证刻意更窄。原因是:公开 ID 必须能被"已持久化会话"与"CLI resume"路径寻址到,因此调用方 ID 必须是内部 ID 的严格子集。
源码中这两套正则非常直观(见 packages/cli/src/config/session-id.ts):
// 内部 ID:允许可选的 -agent-* Arena 后缀 const INTERNAL_SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i; // 调用方 ID:严格 RFC UUID v1-v5,无后缀 const CALLER_SUPPLIED_SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;正则中的[1-5]限定版本号 1–5,[89ab]限定变体位,共同保证了"RFC 变体 UUID v1–v5"这一约束。而normalizeSessionIdForLookup只在命中调用方正则时才做小写化,内部 Arena ID 与历史遗留 ID 保持原有拼写不变(packages/cli/src/config/session-id.ts)。
解析入口parseCallerSuppliedSessionId返回三态结果absent | invalid | valid:undefined/null为absent,非字符串或正则不匹配为invalid,合法则返回规范化后的小写 ID(同文件第 34–45 行)。
2.3 "指定 ID"不等于"幂等附加"
提供一个 ID 的含义是"用这个 ID 创建一个全新的独立线程会话(thread session)",而不是幂等的 attach 操作。因此在创建响应出现歧义(例如网络中断、超时)之后,调用方应当用已知 ID 走load / resume路径来恢复状态,而不是再次 create。
三、所有权边界:各层职责
设计文档用一张职责表明确了各层的分工边界,这也是理解整个架构的钥匙:
| 层 | 职责 |
|---|---|
| 共享解析器 | 公开 UUID 校验与小写规范化;内部 Arena 兼容性保持独立 |
RequestedSessionIdAdmission | daemon 全局的 live / pending / persisted 三方冲突检测(create 与 restore 共用) |
| REST 与 ACP 分发器 | 解析协议字段、副作用前获取准入、错误映射、校验返回的 ID |
| ACP bridge | 强制直接调用方指定的 create 进入 thread 作用域、转发 ID、纳入 fresh-session 容量准入 |
| stdio ACP agent | 访问设置/文件系统前先校验、同 ID 启动串行化、出错时保留共享子进程 |
| SDK 与 MCP 客户端 | 协商session_id_override能力、序列化字段、校验成功响应 |
容量准入与 ID 唯一性刻意分离:既有的"总会话数准入控制器"只负责容量与 drain 状态;ID 唯一性由 admission 组件独立负责。这样两个策略不会误释放或误计数对方的 reservation。
四、Daemon 级准入机制(Admission)
4.1 内存状态模型
admission 组件持有一张以规范化会话 ID 为键的内存 Map(见 packages/cli/src/serve/session-id-admission.ts),claim 分为两类:
create:由某一个 bridge 世代独占;restore:由某一个 bridge 世代持有,带引用计数,允许该世代上的并发 load/resume 调用共享同一次恢复。
4.2 create 的五步流程
- 枚举 daemon 提供的每一个 live bridge,包括正在 draining、以及尚未完成关闭的被替换世代(
getBridges()); - 拒绝任何 live owner 或 pending claim;
- 在第一次异步持久化、branch 或 worktree 操作之前,同步安装 pending create claim——这保证了即便后续扫描失败,同一 ID 也无法被并发请求再次抢占;
- 在会话归档协调器(
SessionArchiveCoordinator)的共享锁下,扫描每一个当前已注册的工作区,每个工作区用其运行时捕获的sessionRuntimeBaseDir固定一个SessionService实例; - 若发现active、archived 或 worktree 支撑的持久化历史则拒绝,否则返回一个绑定身份的 reservation。
其中第 4 步的持久化存在性检查(requestedSessionIdPersistenceExists,同文件第 72–98 行)会做两件事:用findSessionIdIgnoringCase做大小写不敏感的查找(并把SessionIdCaseConflictError也视为占用,即"仅大小写不同的 transcript 也算冲突"),随后对active、archived两种归档状态的 worktree 路径做access探测,非ENOENT错误向上抛出。
4.3 restore 语义
restore不做磁盘扫描,因为它的目的是打开既有历史。它的规则是:
- 拒绝其他 bridge 世代上的 live 或 pending owner;
- 仅当已存在的 restore claim属于同一个 bridge 对象时才共享(同 bridge 上不同调用之间工作区拼写可能不同);
- 否则安装一个新的 restore claim。
4.4 幂等释放与陈旧释放防护
Reservation 是幂等的,只有当 Map 中仍是它捕获的那个精确状态对象时才删除状态。这个身份比对防止了一次延迟的 release 误删同一 ID 上更新的 claim。restore 引用逐个递减,归零时移除状态(同文件第 212–229 行的createReservation)。
4.5 失败语义:fail closed
- bridge 枚举或持久化检查出错时fail closed,返回可重试的
session_id_admission_unavailable; - 普通的
SessionNotFoundError只表示"该 bridge 不拥有此 ID",不属于失败; - 持久化读取失败不继承
SessionService的"读错当存在"行为:扫描会把它们暴露为可重试的503 session_id_admission_unavailable,而不是409冲突; - 客户端应限制 503 重试次数——一个永久不可读的 transcript 目录会持续返回 503,永远不会自行恢复。
测试套件 packages/cli/src/serve/session-id-admission.test.ts 覆盖了这些语义,包括:"扫描完成前同步 claim"(第 117 行)、"检查每一个 live bridge 与每一个注册的持久化目标"(第 144 行)、"仅大小写不同的 transcript 视为持久化占用"(第 227 行)、"restore claim 仅在同一 bridge 世代共享"(第 251 行)、"混合大小写 UUID 视为同一 restore claim"(第 290 行)、"陈旧 release 不删除新 claim"(第 357 行)、"扫描失败返回可重试 unavailable 并释放"(第 384 行)、"bridge 不可枚举时 fail closed"(第 469 行)。
五、运行时替换与工作区范围
5.1 动态 bridge provider
生产环境向 admission 传入一个由运行时生命周期数组支撑的动态 bridge provider。被替换的 bridge 在确认关闭之前一直留在数组中,因此当旧世代还在 draining 时,新世代无法创建同一个 ID——从机制上堵死了"替换窗口内的重名"。
启用运行时替换/移除(runtime replacement / removal)时若没有该 provider,则属于启动错误。
在 packages/cli/src/serve/server.ts 中可以看到生产装配:getBridges来自workspaceRegistry.listManaged().map(runtime => runtime.bridge),getPersistenceTargets使用每个运行时的sessionRuntimeBaseDir,getBridgeWorkspaceId则用于在冲突响应中标注外部 owner 的工作区 ID。
5.2 跨工作区唯一性
持久化扫描使用当前工作区注册表与每个运行时固定的 base 目录,不依赖环境中的存储上下文。这保证了:
- 对当前注册在 daemon 下的所有工作区而言 ID 是唯一的;
- 同时避免了引入新的全局磁盘索引。
历史重复数据不会被迁移或重命名:如果某个包含历史重复 ID 的工作区稍后才被注册,workspace 限定的路由(workspace-qualified routing)继续负责消歧;admission 的保证仅相对于准入时刻已注册的运行时成立。
六、传输行为:REST 与 ACP
6.1 请求形态
- REST:
POST /session { sessionId }——在 packages/cli/src/serve/routes/session.ts 中,路由先parseCallerSuppliedSessionId(body['sessionId']),invalid立即返回400 invalid_session_id(并给出示例"550e8400-e29b-41d4-a716-446655440000"),valid则调用requestedSessionIdAdmission.reserveCreate(...),错误经sendRequestedSessionIdAdmissionError映射。 - ACP:
session/new._meta["qwen-code/sessionId"]。
两者使用同一个 admission 实例,包括主 ACP 挂载与 workspace 限定的 ACP 挂载;REST 与 ACP 的 load/resume 也共享 restore claim,从而关闭了跨传输竞态。
6.2 强制 thread 作用域与 ID 核实
两条 create 路径都会强制sessionScope: "thread"(ACP 分发器在 packages/cli/src/serve/acp-http/dispatch.ts 附近无论客户端参数如何都固定发送sessionScope: 'thread')。
bridge 返回后,分发器比对实际 ID 与请求 ID:
- 不一致返回
session_id_not_honored,并在释放准入前删除新建的 live 与持久化孤儿,避免留下脏状态。
6.3 stdio ACP agent
stdio agent 在加载设置之前重复校验。它用每个子进程的 pending 集合守卫"指定 ID 的 create"与"非 live 的 load/resume 启动":
- 重复启动返回结构化的 ACP
INVALID_PARAMS错误; - 绝不退出进程、不损害兄弟会话;
- 核心
Config保持不变,仍接收throwOnSessionIdConflict作为最终的磁盘冲突防线。
七、公共兼容性与 SDK 行为
7.1 能力协商(capability gate)
daemon 对外宣告session_id_override能力(见 packages/cli/src/serve/capabilities.ts,{ since: 'v1' })。TypeScript、Java 与 daemon MCP 客户端在发送带指定 ID 的 create 变更之前必须先确认该能力,防止旧版 daemon 静默忽略新增字段。
7.2 各 SDK 的字段映射
| SDK / 客户端 | 映射方式 |
|---|---|
| TypeScript | CreateSessionRequest.sessionId,按活动传输映射为 REST JSON 字段或 ACP_meta;发送前requireCapability('session_id_override')(packages/sdk-typescript/src/daemon/DaemonClient.ts) |
| Java | CreateSessionRequest.Builder.sessionId(String);发送前检查capabilities.supports("session_id_override") |
| MCP | session_create.session_id |
7.3 成功响应的二次校验
每个 SDK 都会对成功响应再做一次检查:
- TypeScript:校验响应中的
sessionId与请求一致; - Java:请求 ID 会被
toLowerCase(Locale.ROOT)后与返回 ID 比对,不一致时抛出SessionCreationOutcomeUnknownException——因为意外会话可能已被创建(packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClient.java); - 同样,Java 在 IO 异常、传输异常以及歧义突变状态(
isAmbiguousMutationStatus)下也会抛出SessionCreationOutcomeUnknownException(同文件第 184–199 行),语义是"创建结果未知,请用 ID 走 load/resume"。
Web UI 消费方继承 TypeScript 的可选字段但不主动设置;Python SDK 没有 daemon 客户端,保持不变。
八、错误契约
设计文档给出了完整的 REST / ACP 错误映射表,这是客户端落地时必须对齐的契约:
| 条件 | REST | ACP |
|---|---|---|
| 请求 ID 非法 | 400 invalid_session_id | INVALID_PARAMS,data.httpStatus=400 |
| create 与 live / pending / persisted 状态冲突 | 409 session_id_conflict | INVALID_PARAMS,data.httpStatus=409 |
| restore 属于另一运行时世代 | 409 session_workspace_conflict | INVALID_PARAMS,data.httpStatus=409 |
| 无法检查 live 或持久化所有权 | 503 session_id_admission_unavailable,retryable: true | internal error,data.httpStatus=503,retryable: true |
| 下游返回了不同 ID | 500 session_id_not_honored | internal error,data.httpStatus=500 |
admission 层的错误类型在源码中被建模为RequestedSessionIdAdmissionError,其code精确对应三种错误码:session_id_conflict、session_workspace_conflict、session_id_admission_unavailable,details中携带conflict种类(live | pending | persisted)、工作区路径与 ID、以及retryable标记(packages/cli/src/serve/session-id-admission.ts)。
九、被否决的替代方案
设计文档明确记录了两个被否决的方向,理解它们有助于把握最终取舍:
- 持久化的 daemon 全局 ID 索引:会让未来工作区注册更易推理,但引入了事务性恢复、迁移与陈旧条目清理——而这一特性本就可以通过"当前 live bridges + 既有会话存储"强制实现,因此拒绝。
- 每路由独立 Map:局部更小,但无法关闭 REST/ACP 或跨工作区竞态,拒绝。
- 把 create 视为幂等 attach:会掩盖歧义的变更结果,并与 ACP
session/new的语义冲突,拒绝。
十、验证体系
10.1 单元覆盖清单
单元测试覆盖(见 packages/cli/src/serve/session-id-admission.test.ts):
- UUID 版本与变体、规范化、Arena 兼容性;
- 同步 claim(扫描未完成即拒绝并发);
- 全部 live bridge 世代与固定的持久化目标(pinned persistence targets);
- restore 引用计数、失败释放、陈旧释放;
- 结构化 stdio 错误、作用域强制(scope forcing)、孤儿清理;
- 能力门控、传输映射、SDK 响应校验。
10.2 手工 daemon 场景
设计文档给出的端到端手工验证流程:
- 通过raw REST、TypeScript REST/ACP、Java、MCP分别创建混合大小写的固定 ID;
- 对胜出会话发起 prompt 并持久化;
- 重启 daemon 并 restore该会话;
- 验证跨工作区与跨传输的冲突均被正确拒绝;
- 确认非法的直接 ACP metadata 既不创建文件,也不终止共享子进程。
结语
调用方指定会话 ID 是 qwen-code daemon 为"客户端原子化持久化会话身份"场景提供的一项全局契约。它用一套内存级、同步安装 claim、幂等释放的 admission 机制,在不引入全局磁盘索引的前提下,同时保障了跨工作区、跨 REST/ACP 传输、跨运行时世代的 ID 唯一性;再辅以能力协商、返回 ID 二次校验与完整错误映射,让 TypeScript、Java、MCP 各端客户端都能安全可靠地使用该能力。无论是编排流水线预登记会话身份,还是构建需要"先定 ID 再落状态"的工作流,都可以直接对照本文的契约与源码路径落地。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考