# Session 生命周期与身份 ## 概览 daemon **session** 是一段绑定到一个 ACP `sessionId` 的逻辑对话。bridge 为每个 session 维护一个 `SessionEntry`(见 [`03-acp-bridge.md`](./03-acp-bridge.md)),把 ACP child connection 与 HTTP 侧的簿记捆在一起:prompt FIFO、model-change FIFO、event bus、pending permission、attach 的客户端、心跳、restore 状态、终态 tombstone。 daemon **客户端**由 `X-Qwen-Client-Id` 标识 —— 一段不透明、由 daemon 校验的字符串,调用方自行在请求里盖。daemon 自己不会替调用方生成 id;客户端自取并复用,daemon 据此归属投票、审计事件、识别重连。 本文讲清每一次 session 状态迁移(create / attach / load / resume / close / die / evict)以及 daemon 暴露的每个身份相关 surface。 ## 职责 - 创建、attach、restore、回收 session。 - 校验 `X-Qwen-Client-Id`,错的格式直接拒。 - 跟踪 session 上多个 attach 的客户端(`clientIds: Map`、`attachCount`)。 - 给出站事件盖 `originatorClientId`。 - 跑心跳,让 dashboard 知道谁还在连着。 - 提供 `displayName`,operator 通过 `PATCH /session/:id/metadata` 设置。 - 推送终态帧(`session_died`、`session_closed`、`client_evicted`、`stream_error`)。 ## 架构 | 关注点 | 源 | 说明 | | ------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------- | | `SessionEntry` | `packages/acp-bridge/src/bridge.ts` | 每 session 结构体,字段列表见 [`03-acp-bridge.md`](./03-acp-bridge.md) | | `BridgeSession`(对外) | `packages/acp-bridge/src/bridgeTypes.ts` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` 回给 HTTP handler | | `BridgeSessionState` | `packages/acp-bridge/src/bridgeTypes.ts` | `LoadSessionResponse \| ResumeSessionResponse`,缓存为 `restoreState` | | `DaemonSession`(SDK) | `packages/sdk-typescript/src/daemon/types.ts` | `{ sessionId, workspaceCwd, attached, clientId?, createdAt? }` | | ClientId 校验 | `packages/acp-bridge/src/bridge.ts`(`spawnOrAttach` 附近) | 正则 `[A-Za-z0-9._:-]{1,128}`,违法抛 `InvalidClientIdError` | | Session disconnect-reaper | `packages/cli/src/serve/server.ts` | 用 `attachCount` + `spawnOwnerWantedKill` 跟踪 spawn 拥有者断连 | ### 状态机 ```mermaid stateDiagram-v2 [*] --> SpawnInProgress: POST /session SpawnInProgress --> Live: newSession success SpawnInProgress --> [*]: initialize failure / spawn error Live --> Live: attach (sessionScope=single, bump attachCount) Live --> Live: detach (decrement attachCount) Live --> RestoreInProgress: POST /session/:id/load or /resume RestoreInProgress --> Live: restoreState cached on entry RestoreInProgress --> Live: RestoreInProgressError (coalesce waiters) Live --> Closed: DELETE /session/:id (last client) Live --> Died: ACP child exit / channel.exited fired Closed --> [*]: session_closed terminal frame Died --> [*]: session_died terminal frame ``` ### Attach 与 Spawn `sessionScope: 'single'`(默认)下,bridge 的 `defaultEntry` 被所有连进来的客户端共享。`POST /session` 到来时 `defaultEntry` 已存在 → 不 spawn 新 ACP child,直接返回 `attached: true`。bridge 同步 bump `attachCount` 并把调用方的 `X-Qwen-Client-Id` 登记到 `clientIds`。 `sessionScope: 'thread'`:每次 `POST /session` 新建一个 session。仍然受 `maxSessions` 约束。 ### 身份 `X-Qwen-Client-Id` **可选**但**强烈建议**带。daemon 不会替调用方生成;客户端自己挑、在所有请求里复用,daemon 才能归属投票、审计事件、识别重连。 校验: - 字符集 `[A-Za-z0-9._:-]`。 - 长度 1–128。 - 不合规 → `InvalidClientIdError`(`400`)。 daemon 在以下条件全部满足时给出站 SSE 事件盖 `originatorClientId`: 1. 触发该事件的请求带了 `X-Qwen-Client-Id`,且 2. 该 id 已登记在 session 的 `clientIds` 集合里,且 3. session 当前有 `activePromptOriginatorClientId`(在跑的 prompt 的内联 `sessionUpdate` 和 `permission_request` 继承该 originator)。 匿名调用(不带 `X-Qwen-Client-Id`)在 `first-responder` 下可用;`designated` 会拒它的投票为 `permission_forbidden{ reason: 'designated_mismatch' }`;`consensus` 同样拒为 `forbidden`(不在发起时 `votersAtIssue` 快照中);`local-only` 是唯一接受匿名 loopback 投票者的策略。 ## 流程 ### Create or attach ```mermaid sequenceDiagram autonumber participant C as Client participant R as POST /session participant B as Bridge.spawnOrAttach participant CH as ACP child C->>R: POST /session
X-Qwen-Client-Id: alice
{cwd, sessionScope?} R->>R: validate clientId pattern R->>B: spawnOrAttach({cwd, sessionScope, clientId}) alt single scope + defaultEntry exists B->>B: bump attachCount; register clientId B-->>R: {sessionId, attached: true, restoreState?} else cold B->>CH: spawn + ACP initialize + newSession CH-->>B: sessionId B->>B: build SessionEntry; register in byId B-->>R: {sessionId, attached: false} end R-->>C: 200 { sessionId, attached, ... } ``` ### Load / Resume - `POST /session/:id/load` — 重放完整 ACP 历史(`session/load` 通知先于响应返回)。 - `POST /session/:id/resume` — 不重放(`connection.unstable_resumeSession`,由 `unstable_session_resume` 能力暴露)。 两者都: 1. 在 channel 的 `pendingRestoreIds` 集合里登记,让并发 restore 合并(`RestoreInProgressError`)。 2. 把 `restoreState` 缓存到 entry,让晚到的 attacher 看到与原始 restore 调用一致的 payload。 ### 心跳 `POST /session/:id/heartbeat` 不管带不带 `clientId` 都会更新 `sessionLastSeenAt`。如果请求带了已登记的 `X-Qwen-Client-Id`,`clientLastSeenAt.set(clientId, Date.now())` 也会 bump。v1 **没有** per-client 剔除;revocation 是 F 系列 Wave 5。当前心跳的价值是给 dashboard / 给将来的 PR 24 撤权策略提供观测。 ### Metadata `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 exit 路径下带 `exitCode?` + `signalCode?` | | `client_evicted` | EventBus 每订阅者队列溢出(见 [`10-event-bus.md`](./10-event-bus.md)),**非** session 级终态,仅关掉当前订阅者 | | `stream_error` | `SubscriberLimitExceededError` 或其他路由流错误 | 每个终态路径都会 `mediator.forgetSession(sessionId)`,把所有 pending permission 解析为 `{kind:'cancelled', reason:'session_closed'}`。 ### Disconnect-reaper 守护 spawn 拥有者的 HTTP 响应写不出去时(TCP 在握手中途 reset),路由会 `killSession({ requireZeroAttaches: true })`。如果其他客户端已经 attach 了(`attachCount > 0`),bail 短路、session 继续活着,但 `spawnOwnerWantedKill = true` 留作 tombstone;之后某次 `detachClient()` 把 `attachCount` 拉回 0 时完成延迟回收。没有这个守护,spawn 拥有者快速断连会每隔一次重连就拆掉一个健康 session。 ## 状态与生命周期 `SessionEntry` 中和生命周期最密切的字段: | 字段 | 类型 | 含义 | | -------------------------------- | --------------------- | --------------------------------------------------------------- | | `clientIds` | `Map` | 已登记 clientId → 引用计数 | | `attachCount` | `number` | `spawnOrAttach` 对该 entry 返回 `attached: true` 的次数 | | `activePromptOriginatorClientId` | `string?` | 当前在跑的 prompt 的 originator | | `restoreState` | `BridgeSessionState?` | load/resume 响应缓存,让晚到 attacher 看到一致 payload | | `spawnOwnerWantedKill` | `boolean` | 延迟回收 tombstone | | `sessionLastSeenAt` | `number?` | 任何客户端最近一次心跳(epoch ms) | | `clientLastSeenAt` | `Map` | per-client 心跳 | | `pendingPermissionIds` | `Set` | 当前 pending 的 ACP requestId — cancel/close 时解析为 cancelled | ## 依赖 - ACP 层:`connection.newSession`、`connection.unstable_resumeSession`、`connection.loadSession`。 - [`03-acp-bridge.md`](./03-acp-bridge.md) — 周围的 bridge 架构。 - [`04-permission-mediation.md`](./04-permission-mediation.md) — originator + identity 如何驱动策略。 - [`10-event-bus.md`](./10-event-bus.md) — 终态帧投递。 ## 新增 session 端点(daemon_mode_b_main) 以下端点在基础生命周期之上扩展了 session 的能力: ### Non-blocking Prompt(`non_blocking_prompt` 能力 tag) `POST /session/:id/prompt` 现在返回 HTTP **202** `{ promptId, lastEventId }`,不再阻塞直到 prompt 完成。实际结果通过 SSE 上的 `turn_complete` / `turn_error` 事件投递,`promptId` 字段与 202 响应关联。SDK `DaemonSessionClient.prompt()` 在有活跃事件订阅时自动走 non-blocking 路径,透明地通过 SSE 流匹配结果。 ### Session Recap(`session_recap` 能力 tag) `POST /session/:id/recap` —— 使用 fast model 对 session 生成一句话 "where did I leave off" 摘要。返回 `{ sessionId, recap: string | null }`,`null` 表示历史太短或模型暂时失败。best-effort。 ### Session BTW / Side Question(`session_btw` 能力 tag) `POST /session/:id/btw` —— 在不中断主对话流的情况下针对 session 的上下文问一个一次性问题。使用 `runForkedAgent`(cache 路径)做单 turn、无工具的 LLM 调用。返回 `{ sessionId, answer: string | null }`。有输入长度限制(`BTW_MAX_INPUT_LENGTH`)、跨 session 泄漏防护和超时处理。 ### Shell Command Execution `POST /session/:id/shell` —— 直接在 daemon 宿主上执行 shell 命令(不经过 LLM)。通过 session SSE bus 流式输出(`user_shell_command` / `user_shell_result` 事件),并把命令和结果注入 LLM 的聊天历史。返回 `{ exitCode, output, aborted }`。 ### Session Detach `POST /session/:id/detach` —— 显式解除客户端与 session 的绑定(减 `attachCount`),不关闭 session。如果没有其他 attach/subscriber 存活则回收 session。返回 204。 ### Batch Session Delete `POST /sessions/delete` —— 接受 `{ sessionIds: string[] }`(最多 100 个),关闭 bridge session 并删除 transcript 文件。使用 `Promise.allSettled` 保证弹性。返回 `{ removed, notFound, errors }`。 ### Context Usage(`session_context_usage` 能力 tag) `GET /session/:id/context-usage` —— 返回 session 的 context window 结构化使用量。`?detail=true` 返回按 tool/memory/skill 分类的细粒度用量。 ### Session Stats(`session_stats` 能力 tag) `GET /session/:id/stats` —— 返回 session 使用统计:模型指标(input/output tokens、cache reads/writes、total cost)、per-tool 调用次数和耗时、文件编辑次数。 ### Session Tasks(`session_tasks` 能力 tag) `GET /session/:id/tasks` —— 返回 session 的后台任务快照:agent 任务、shell 任务、monitor 任务及其生命周期状态。 ### Compacted Replay `POST /session/:id/load` 返回的 `BridgeRestoredSession` 现在包含 `compactedReplay?: BridgeEvent[]`、`liveJournal?: BridgeEvent[]`、`lastEventId?: number`。`compactedReplay` 由 `TurnBoundaryCompactionEngine` 生成:在 turn 边界折叠连续文本/思考块、工具调用序列折到最终状态、丢弃瞬态信号,产出 O(turns) 而非 O(tokens) 量级的重放日志(通常 25-30x 压缩)。 ### ACP Child Preheat `bridge.preheat()` —— 提前预热 ACP child 进程,让第一个 session 不付冷启动延迟。配合 `channelIdleTimeoutMs`(最后 session 关闭后保持 ACP child 存活的时间)和 skip-relaunch(新 session 到达时复用已有的空闲 child)使用。 ## 配置 - `BridgeOptions.maxSessions`(默认 20)。 - `BridgeOptions.sessionScope`(默认 `'single'`,可选 `'thread'`)。 - `BridgeOptions.initializeTimeoutMs`(默认 10s)。 - `BridgeOptions.channelIdleTimeoutMs`(默认 0,= 立即回收 ACP child)。 - 能力 tag:`session_create`、`session_scope_override`、`session_load`、`unstable_session_resume`、`session_list`、`session_close`、`session_metadata`、`session_set_model`、`client_identity`、`client_heartbeat`、`session_recap`、`session_btw`、`session_context_usage`、`session_tasks`、`session_stats`、`non_blocking_prompt`。 ## 注意 & 已知局限 - `connection.unstable_resumeSession` 不稳定;ACP 方法形状还可能变。能力 tag 故意带 `unstable_` 前缀,让客户端 feature-detect 而不是硬绑 v1。 - v1 **没有** per-client 剔除,只有 per-session 与 per-subscriber 终态。撤权策略是 F 系列 Wave 5 / PR 24。 - `client_evicted` 是 per-subscriber 不是 per-session;订阅者被剔除的客户端可以重连。 - 匿名客户端在 `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` - Wire 参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。