Session 生命周期与身份标识
概述
守护进程 session 是绑定到单个 ACP sessionId 的一次逻辑对话。bridge 为每个 session 维护一个 SessionEntry(参见 03-acp-bridge.md),它将 ACP 子连接与 HTTP 端的记录逻辑耦合在一起:包括 prompt FIFO、model-change FIFO、事件总线、待处理权限、已附加客户端、心跳、恢复状态以及终端帧墓碑。
守护进程 client 通过 X-Qwen-Client-Id 进行标识——这是一个不透明的、由守护进程验证的字符串,HTTP 调用方会将其标记在请求中。bridge 跟踪哪些 client 附加到了哪些 session,并使用 originator client id 来驱动 designated 权限策略、审计跟踪和事件归因。
本文档解释了每个 session 生命周期转换(create / attach / load / resume / close / die / evict)以及守护进程暴露的每个身份接口。
职责
- 创建、附加、恢复和回收 session。
- 验证
X-Qwen-Client-Id并拒绝格式错误的 id。 - 跟踪每个 session 附加的多个 client(
clientIds: Map<string, count>、attachCount)。 - 在出站事件上标记
originatorClientId。 - 运行心跳机制,以便仪表盘了解哪些 client 仍处于连接状态。
- 暴露操作员通过
PATCH /session/:id/metadata设置的 session 元数据(displayName)。 - 驱动终端帧的发送(
session_died、session_closed、client_evicted、stream_error)。
架构
| 关注点 | 源码位置 | 说明 |
|---|---|---|
SessionEntry | packages/acp-bridge/src/bridge.ts | 每个 session 的结构体;完整字段列表请参见 03-acp-bridge.md。 |
BridgeSession (public) | packages/acp-bridge/src/bridgeTypes.ts | 返回给 HTTP handler 的 { sessionId, workspaceCwd, attached, clientId?, createdAt? }。 |
BridgeSessionState | packages/acp-bridge/src/bridgeTypes.ts | 作为 restoreState 缓存在 entry 上的 LoadSessionResponse | ResumeSessionResponse。 |
DaemonSession (SDK) | packages/sdk-typescript/src/daemon/types.ts | { sessionId, workspaceCwd, attached, clientId?, createdAt? }。 |
| Client-id 验证 | packages/acp-bridge/src/bridge.ts(spawnOrAttach 附近) | 正则模式 [A-Za-z0-9._:-]{1,128};格式错误时抛出 InvalidClientIdError。 |
| Session 断连回收器 | packages/cli/src/serve/server.ts | 使用 attachCount + spawnOwnerWantedKill 跟踪 spawn-owner 的断连。 |
状态机
Attach vs spawn
在 sessionScope: 'single'(默认)下,bridge 的 defaultEntry 由每个连接的 client 共享。当 defaultEntry 已存在时,到达的 POST /session 请求会返回 attached: true,而不会 spawn 新的 ACP 子进程。bridge 会同步增加 attachCount,并将调用方的 X-Qwen-Client-Id 注册到 clientIds 中。
在 sessionScope: 'thread' 下,每个 thread 可以创建一个独立的 session。调用方仍需遵守 maxSessions 限制。
身份标识
X-Qwen-Client-Id 是可选的,但强烈建议使用。守护进程不会代为生成——client 需要自己选择并在请求中复用,以便守护进程进行投票归因、事件审计和重连检测。
每个独立的控制器应使用不同的、稳定的 ID。Web Shell 为兼容性保留历史使用的 webui_ 前缀。宿主和嵌入式 Web Shell 仅在有意识地作为单个逻辑控制器行动时才应共享 ID;一旦共享,daemon 日志将无法区分是哪个发起了请求。
验证规则:
- 字符集:
[A-Za-z0-9._:-]。 - 长度:1–128。
- 超出此字符集:抛出
InvalidClientIdError(400)。
守护进程会在出站 SSE 事件上标记 originatorClientId,条件如下:
- 触发该事件的请求携带了
X-Qwen-Client-Id,并且 - 该 id 当前已注册在 session 的
clientIds集合中,并且 - session 设置了
activePromptOriginatorClientId(内联的sessionUpdate和permission_request会继承活跃 prompt 的 originator)。
匿名调用方(无 X-Qwen-Client-Id)在 first-responder 策略下可以正常工作;designated 会以 permission_forbidden{ reason: 'designated_mismatch' } 拒绝其投票;consensus 也会以相同的 forbidden 原因拒绝,因为投票者不在 issue-time 的 votersAtIssue 快照中;local-only 是唯一接受匿名 loopback 投票者的策略。
工作流
创建或附加
Load / resume
POST /session/:id/load — 恢复持久化的会话并返回当前有界重放快照窗口(session/load 通知或响应模式重放在响应返回前播种)。
POST /session/:id/resume — 恢复但不重放(connection.unstable_resumeSession,在稳定的 session_resume 守护进程能力下暴露;unstable_session_resume 仍作为已弃用的别名保留)。
两者均:
- 在 channel 上使用每个 session 的
pendingRestoreIds集合,以便合并并发的 restore 调用(RestoreInProgressError)。 - 在 entry 上缓存
restoreState,以便后附加的 client 获取与原始恢复者相同的有效载荷。
对于持久化的 Part 4A worktree 会话,恢复是此生命周期的完整性门控扩展。sidecar 显式标识请求的工作区根,checkout 必须规范地包含在相应的 .qwen/worktrees/ 目录下,并且其标记必须是包含确切恢复会话 ID 的单链接常规文件。守护进程仅在这些检查通过后才重新定位空闲的已恢复子进程;活跃子进程仅在其报告的 cwd 已等于 worktree 时才被接受,而其报告的 cwd 缺失或在其他位置的活跃子进程会 fail closed 而不是在其 prompt 下被重新定位——除了无法延迟其恢复 prompt 的冷恢复(suppressWorktreeContextRestore 关闭,因此 bridge 触发了重新挂起的问题而不是停放它):该形态保留 4B 之前的结果,返回不带 worktreeState 的规范 worktree 元数据,未重新定位,会话继续存活。重新定位和接受的响应返回带有 worktreeState: "persisted-v1" 的规范 worktree 元数据。携带 supersededBy 的 sidecar 永远不会恢复:路由返回 409 worktree_session_superseded 及替换会话 id,该分类仅根据该链接在任何标记读取之前决定,因此调用者仅在该 id 的加载成功后才重定向并修复其记录——预提交中断的传输命名的替换不是标记所有者,其本身无法恢复,并被重试的 reset 回收;supersedes 链接与旧 sidecar 一致而标记未移动(或缺失)的已恢复替换返回 409 worktree_reset_interrupted,其修复方式是对被取代会话重试 reset;缺少该一致链接对的缺失标记返回 409 worktree_marker_missing,其修复方式是重置任务而不是重试恢复,因为没有恢复路径会重新创建标记。中断分类优先检查。无效的 Part 4A 状态会分离现有附加或以 requireZeroAttaches 终止冷恢复;缺少 sidecar 同样无法提供证明。当有效恢复源为 Channel 所拥有时,路由会抑制 ACP 代理对 Part 4A 或无法分类的 sidecar 状态的尽力清理,因此验证失败会保留不确定的 checkout 证据。持久化源元数据优先;当其缺失时,load/resume 请求提供有效源。结构上有效的旧版 sidecar(无 workspaceCwd)保留现有的尽力代理恢复:它必须标识请求的工作区根或其 Git 仓库顶层,进行无标记证明的包含检查,可能被代理清理,并可能返回不带 worktreeState 的 worktree。除该显式旧版兼容情况外,仅有效恢复源非 Channel 所拥有的会话保留路由验证前的现有尽力清理。
Worktree 所有权转移(POST /session/:id/worktree-reset,由 session_worktree_reset_v1 宣传)为此生命周期扩展了 Channel 任务重置:守护进程在根工作区中生成新的线程作用域替换,将其重新定位到已验证的 checkout 中,链接 sidecar 对(先在旧会话上设置 supersededBy,然后在替换上设置 supersedes),在每个 checkout 路由锁和准入屏障下将标记翻转到替换,该屏障拦截 prompt 准入以及在 checkout 中开始工作或移动会话 cwd 的另外七个写入者(rewind、cwd 变更、branch、fork、shell、goal control、workflow-task action),而 release 和 stop 路径设计上不设屏障,然后断开被取代会话的客户端注册和内存中的 worktree 关联。断开报告会报告被取代会话是否确实已消失:子进程仍持有后台工作的存活者保持屏障激活,被记录,并向调用者报告为 supersededSessionLive: true,而不是被成功响应掩盖。在传输过程中在被取代会话上被准入的屏障写入者被拒绝并返回 409 worktree_reset_active;完整的失败分类(包括重试回滚哪些崩溃窗口以及哪些 fail closed 交由操作员修复)记录在 qwen-serve-protocol.md 的该路由文档中。
心跳
POST /session/:id/heartbeat 会更新 sessionLastSeenAt,无论是否携带 clientId。如果请求携带了已注册的 X-Qwen-Client-Id,还会执行 clientLastSeenAt.set(clientId, Date.now()) 进行更新。v1 中未实现按 client 驱逐;撤销功能计划在 F-series Wave 5 中推出。目前,心跳机制为仪表盘以及 PR 24 中即将推出的撤销策略提供可观测性。
元数据
PATCH /session/:id/metadata 接受 {displayName?}。验证规则:
- 最大长度:
MAX_DISPLAY_NAME_LENGTH = 256。 - 不得包含控制字符(
hasControlCharacter会拒绝码点 ≤ 0x1f 或 == 0x7f 的字符)。 - 违反时抛出
InvalidSessionMetadataError(400)。
成功更新后,会向每个订阅者广播 session_metadata_updated 事件。
终止
| 终端帧 | 触发条件 |
|---|---|
session_closed | DELETE /session/:id (client_close) 或编程式关闭。 |
session_died | channel.exited 因任何原因触发(崩溃、子进程被 kill)。当使用 OS 退出路径时,会携带 exitCode? + signalCode?。 |
client_evicted | EventBus 上的单订阅者队列溢出(参见 10-event-bus.md)。这不是 session 级别的终止——仅关闭该订阅者。 |
stream_error | SubscriberLimitExceededError 或其他路由级别的 stream 失败。 |
在每个终止路径中,通过 mediator.forgetSession(sessionId) 将 pending permissions 解析为 {kind:'cancelled', reason:'session_closed'}。
断连回收器守卫
当 spawn-owning client 的 HTTP 响应无法写入(握手期间 TCP 重置)时,路由会调用 killSession({ requireZeroAttaches: true })。如果已有其他 client 附加(attachCount > 0),该守卫会短路,session 继续存活。设置 spawnOwnerWantedKill = true 会记住该意图,以便后续将 attachCount 降回 0 的 detachClient() 完成延迟回收。如果没有此机制,频繁快速断连的 spawn owner 会在每次重连时摧毁一个健康的 session。
状态与生命周期
对生命周期至关重要的 SessionEntry 字段:
| 字段 | 类型 | 含义 |
|---|---|---|
clientIds | Map<string, number> | 已注册的 client id → 注册引用计数。 |
attachCount | number | spawnOrAttach 为该 entry 返回 attached: true 的次数。 |
activePromptOriginatorClientId | string? | 当前正在运行的 prompt 的 originator。 |
restoreState | BridgeSessionState? | 缓存的 load/resume 响应,确保后附加的 client 看到一致的有效载荷。 |
spawnOwnerWantedKill | boolean | 延迟回收墓碑(参见上文的断连回收器)。 |
sessionLastSeenAt | number? | 所有 client 中最近的心跳时间(epoch 毫秒)。 |
clientLastSeenAt | Map<string, number> | 每个 client 的心跳时间。 |
pendingPermissionIds | Set<string> | 当前 pending 的 ACP requestIds —— 在 cancel/close 时用于将其解析为 cancelled。 |
依赖
- ACP 层:
connection.newSession、connection.unstable_resumeSession、connection.loadSession。 03-acp-bridge.md了解周围的 bridge 架构。04-permission-mediation.md了解 originator + identity 如何驱动策略决策。10-event-bus.md了解终端帧的传递。
额外的 session 端点
这些端点扩展了基础生命周期接口:
非阻塞 Prompt(non_blocking_prompt 能力标签)
POST /session/:id/prompt 现在返回 HTTP 202 及 { promptId, lastEventId },而不是阻塞直到 prompt 完成。实际结果会通过 SSE 以 turn_complete / turn_error 的形式到达,并且 promptId 字段将这些事件与 202 响应关联起来。当 DaemonSessionClient.prompt() 拥有活跃的事件订阅时,会自动使用非阻塞路径,并透明地匹配来自 SSE 流的结果。
Session 总结(session_recap 能力标签)
POST /session/:id/recap 向快速模型请求一行“我上次进行到哪里了”的总结。它返回 { sessionId, recap: string | null };null 表示历史记录太短或模型暂时失败。此端点是尽力而为(best-effort)的。
Session BTW / 顺带提问(session_btw 能力标签)
POST /session/:id/btw 针对会话上下文提出一次性问题,且不会中断主对话流。它在缓存路径上使用 runForkedAgent 进行单轮、无工具的 LLM 调用,并返回 { sessionId, answer: string | null }。该实现强制执行 BTW_MAX_INPUT_LENGTH 限制、跨会话泄漏防护以及超时处理。
Shell 命令执行
POST /session/:id/shell 直接在 daemon host 上执行 shell 命令,不经过 LLM 路由。它通过 user_shell_command / user_shell_result 事件在会话 SSE 总线上流式输出结果,并将命令及其结果注入 LLM 对话历史。响应格式为 { exitCode, output, aborted }。对于活跃的次级工作区会话,单一 REST 路由会解析会话所有者并在该 runtime 的 bridge 上执行,因此命令在所属工作区的 cwd 中启动。该路由不提供路径沙箱。具有工作区资格的 ACP 客户端可以继续在所属工作区连接上使用 _qwen/session/shell。
会话回退
GET /session/:id/rewind/snapshots 和 POST /session/:id/rewind 解析所属的活跃工作区 runtime。持久化的会话必须先 load 或 resume 才能回退。回退会截断对话历史并可选地恢复由 edit 和 write_file 跟踪的文件;它不会撤消 shell 命令、Git、脚本或手动更改。文件恢复是尽力而为的,因此响应可能在对话历史已经移动后报告 rewound: false 和 filesFailed[]。SDK 回退调用始终使用所有者感知的 REST,即使客户端在其他情况下使用 ACP 传输也是如此,因为变更必须保留严格的 REST 身份验证。
会话分离
POST /session/:id/detach 通过递减 attachCount 显式将客户端从会话中分离;它本身不会关闭会话。如果没有其他附加(attach)或订阅者存在,该会话将被回收。该端点返回 204。
批量删除会话
POST /sessions/delete 接受 { sessionIds: string[] }(最多 100 个 id),关闭 bridge 会话,并删除活跃或已归档的 transcript 文件。如果同一个 id 同时存在活跃和已归档的 JSONL 文件,硬删除会移除两者,以便运维人员清除冲突。它会清理活跃和已归档的 worktree sidecars,但保留 file-history 快照、子代理 transcript 和运行时 sidecars。它使用 Promise.allSettled 来保证弹性,并返回 { removed, notFound, errors }。
会话归档
POST /sessions/archive 将非活跃会话的 JSONL 文件从 chats/ 移动到 chats/archive/。如果目标会话处于活跃状态,daemon 会先进入每个会话的归档门控(archive gate),并执行严格关闭,要求 ACP 子进程 flush ChatRecordingService;如果关闭或 flush 失败,归档操作会将 JSONL 保留在原位。
POST /sessions/unarchive 将已归档的 JSONL 文件移回 chats/。这仅仅是存储状态的转换;客户端之后必须调用 session/load 或 session/resume。对于已归档的会话,load/resume 会返回 409 session_archived,而在归档转换期间发生竞争的变更操作会返回 409 session_archiving。
空的、损坏的和孤立的常规转录文件即使无法作为对话加载,仍然符合这些生命周期操作的条件。所有权安全检查可以有意地 fail closed 并要求操作员干预。在 writer 密封其认证的交接证明后,如果文件被更改,则会以 SessionTranscriptChangedError 失败,直到操作员解决密封锁和已更改的字节。超过有界所有权读取窗口的 JSON 格式首条物理记录会以 SessionTranscriptIdentityUnavailableError 失败,直到该记录被修复或缩减;带有非对象前缀的超大损坏记录仍然符合条件。可解析的恢复记录必须包含字符串类型的 sessionId 和 cwd 所有权字段,混合的本地/外部归档状态也会 fail closed。当宣传了 session_storage_conflict_repair 时,archive 和 unarchive 接受 resolveConflicts: true:archive 保留已归档的副本,而 unarchive 保留活跃的副本。不使用该选项时,活跃/归档冲突不会移动、删除或覆盖任何持久化副本,并会在批处理的 errors 数组中返回。Archive 仍然在分类冲突之前严格关闭活跃会话,这可能会将排队的记录 flush 到活跃转录中。具有工作区资格的生命周期路由现在使用 HTTP 200 批处理信封,而不是早期的 HTTP 409 session_conflict 响应。
上下文使用情况(session_context_usage 能力标签)
GET /session/:id/context-usage 返回结构化的上下文窗口使用情况。?detail=true 包含按 tool、memory 和 skill 分组的更细粒度的使用情况。
会话统计(session_stats 能力标签)
GET /session/:id/stats 返回使用统计信息:模型指标(输入/输出 tokens、缓存读/写、总成本)、每个 tool 的调用次数和延迟、文件编辑次数,以及当前活跃会话中每个 skill 的调用次数。skills 块仅反映该会话内的 skill body 加载和 skill 斜杠命令;它不是跨会话的活动聚合。
会话任务(session_tasks 能力标签)
GET /session/:id/tasks 返回 agent 任务、shell 任务、monitor 任务及其生命周期状态的后台任务快照。由另一个子代理生成的 agent 条目包含可选的 lineage 字段(parentAgentId、parentName、depth),以便客户端将嵌套的子代理渲染为树状结构;请参阅 qwen-serve-protocol.md 中的 payload 示例。
session_monitor_tool_correlation 能力额外保证 monitor 条目携带 toolUseId,允许客户端将转录中的工具调用与其任务详情进行关联。
会话 LSP 状态(session_lsp 能力标签)
GET /session/:id/lsp 为 daemon 客户端返回经过清理的每个会话的 LSP 状态:启用状态、聚合服务器数量、不可用/初始化状态,以及每个服务器的 name、status、languages、transport、command 和 error。禁用或不可用的 LSP 会表示为 HTTP 200 状态数据,而不是传输错误。
压缩重放
POST /session/:id/load 现在返回一个 BridgeRestoredSession,其中可以包含 compactedReplay?: BridgeEvent[]、liveJournal?: BridgeEvent[] 和 lastEventId?: number。这些字段是守护进程为活跃会话提供的有界内存重放窗口,而非完整的转录 API。默认窗口上限为每个活跃会话 4 MiB(--compacted-replay-max-bytes),启动时拒绝无效上限;硬上限为 256 MiB。compactedReplay 由 TurnBoundaryCompactionEngine 生成:在 turn 边界处,它会折叠连续的 text / thought 块,将 tool-call 序列折叠为其最终状态,丢弃瞬态信号,并生成 O(turns) 级别的重放日志,而不是 O(tokens) 级别的日志(通常可减少 25-30 倍)。当较旧的保留重放从该字节窗口中被丢弃时,compactedReplay[0] 是一个合成的无 id history_truncated 标记,包含 {reason: 'replay_window_exceeded', truncatedEvents, retainedEvents, maxBytes, truncatedTurns?, fullTranscriptAvailable: boolean}。fullTranscriptAvailable 是一个能力标志:true 表示客户端可以使用 GET /session/:id/transcript 翻页获取完整的持久化转录,而 false 表示仅有界重放可用。客户端应将其作为状态渲染并正常应用保留的重放;它不得触发重同步循环。
ACP 子进程预热
bridge.preheat() 仍可供显式嵌入方使用,但 qwen serve 也会在启动后尝试预热 trusted primary child 以保持一致性。预热失败不会导致致命错误,下一个 runtime 命令或 Session 会重试;trusted secondary 在首次使用时启动。Workspace Runtime 在工作活跃期间拥有该子进程。在所有 Session 和管理租约 drain 之后,省略或为零的 channelIdleTimeoutMs 会立即回收该子进程;单纯的预热本身会为首次使用保留,且不会触发回收器。正值的配置延迟或活跃的 keepalive 会使子进程在更长的剩余窗口内保持可复用。公开的 Workspace Runtime ensure 命令会添加一个可续期的十分钟工作区租约;每次成功的调用都会重置该窗口,包括 channel 已经活跃的情况。
配置
BridgeOptions.maxSessions(默认 32)— 上限。BridgeOptions.sessionScope(默认'single';可选'thread')。BridgeOptions.initializeTimeoutMs(默认 10s)— ACP 子进程启动截止时间(Channel factory +initialize握手)及默认请求超时。BridgeOptions.sessionRestoreTimeoutMs(默认 60s)— ACPloadSession/unstable_resumeSession截止时间。默认 60 秒;显式配置的 initialize 超时可以提高此值,但不能降低。BridgeOptions.channelIdleTimeoutMs(未设置或0时在 runtime 工作 drain 后回收,但单纯预热会为首次使用保留;正值或活跃的 keepalive 会延迟回收,且取较长的延迟)。- Capability tags:
session_create、session_id_override、session_scope_override、session_load、session_resume、unstable_session_resume(已弃用的别名)、session_list、session_info、session_close、session_metadata、session_set_model、client_identity、client_heartbeat、session_recap、session_generation、session_btw、session_context_usage、session_tasks、session_monitor_tool_correlation、session_stats、session_lsp、session_resources、session_status、non_blocking_prompt。
无状态 generation(session_generation 能力标签)
POST /session/:id/generate 接受 { "prompt": string } 并返回一个请求作用域的 SSE 流,包含 started、可选的 thinking、delta、done 或 error 事件。该请求不读取对话历史、不记录轮次,也不暴露任何工具。ACP 子进程在可用时使用已配置的有效快速模型,否则使用会话的主模型。
注意事项与已知限制
connection.unstable_resumeSession在 ACP 层可能仍然不稳定,但 daemon 通过session_resume宣传已提交的 v1 路由契约。unstable_session_resume仅作为已弃用的兼容性别名保留。- v1 没有 per-client 驱逐;只有 per-session 和 per-subscriber 终止。撤销策略为 F-series Wave 5 / PR 24。
client_evicted是 per-subscriber 的,而不是 per-session 的。SSE 订阅者被驱逐的客户端可以重新连接。- 匿名客户端(没有
X-Qwen-Client-Id)无法在designated或consensus策略下进行投票。
参考资料
packages/acp-bridge/src/bridge.ts(SessionEntry 定义)packages/acp-bridge/src/bridgeTypes.ts(HttpAcpBridge、BridgeSession、BridgeSessionState)packages/sdk-typescript/src/daemon/types.ts(DaemonSession)packages/sdk-typescript/src/daemon/DaemonSessionClient.ts- 协议参考:
../qwen-serve-protocol.md(路由目录)。