# 会话事件流与持久化投影

> AgentRuntime 怎样对外交代它做了什么：七十九种会话事件的谱系，只在内存里、按回合窗口淘汰的事件存储，appendEvent 的四步管线，EventReducer 投影出的会话状态，消息与 part 怎样与事件并行落库，合成通知的语义标注，标题生成的旁路请求，以及会话驻留。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/session-events
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

`AgentRuntime` 跑起来之后，外界靠什么知道它在做什么？靠两条并行的输出。一条是会话事件 `SessionEvent`：经 `appendEvent` 分配序号，交给订阅者，驱动 TUI、桌面端与 Web 的实时界面。另一条是持久化写入：用户消息、助手消息、各类 part、会话条目与输入账本，经 `SessionStorePort` 写进 SQLite，供冷恢复与历史回放。这一篇讲这两条输出怎么产生、怎么分工。

事件的类型、事件存储与核心 reducer 定义在 `apps/zcode-cli/packages/contracts/src/events`；运行时一侧的写入集中在 `apps/zcode-cli/packages/core/src/runtime/methods` 下的 `events.ts`、`message-persistence.ts`、`timeline-persistence.ts` 等文件。SQLite 的表结构见 [SQLite 会话库](https://daiw.org/manual/zcode/session-store)，协议层怎样把事件归约成界面状态、推给客户端并支持重放，见 [ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol)。

## 不只是事件

说运行时“对外只发事件”并不准确，它有三种对外通道：

- **方法返回值**。受理回执、`TurnResult` 这些直接交给调用方；`TurnResult` 里带着本回合的事件数组与一份会话投影（`apps/zcode-cli/packages/core/src/runtime/types.ts:412`）。
- **会话事件**。所有订阅者都实现同一个一行接口 `onSessionEvent(event)`（`apps/zcode-cli/packages/contracts/src/interfaces/session.port.ts:76`）。订阅有两种方式：构造时经 `deps.eventSink` 传入，或事后调 `subscribeEvents`（`apps/zcode-cli/packages/core/src/runtime/methods/config.ts:161`）。仓库里的三个主要订阅者是协议层（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3417`）、TUI 的事件中继（`apps/zcode-cli/packages/cli/src/tui-session-event-relay.ts:42`）与 `-p` 无头模式（`apps/zcode-cli/packages/cli/src/prompt-command.ts:291`）。
- **持久化写入**。经 `SessionStorePort` 落库，由协议层在冷恢复时读回。

另外还有反向请求：需要审批或提问时，运行时经 `PermissionBrokerPort.requestPermission` 主动问宿主（`apps/zcode-cli/packages/contracts/src/interfaces/permission.port.ts:95`），详见[权限模式与规则](https://daiw.org/manual/zcode/permission)。

```mermaid
flowchart TD
  C["回合、工具、钩子等代码"] -->|"appendEvent"| S["事件存储：分配 seq"]
  S --> D["persistDurableSessionEvent：少数事件写条目与账本"]
  D --> U["recordToolUsageFromEvent：用量统计"]
  U --> K["notifyEventSinks"]
  K --> P["协议层：ProductProjection 与推帧"]
  K --> T["TUI、无头模式"]
  C -->|"persistMessage、persistPart"| DB["SessionStorePort：SQLite"]
  D --> DB
  DB -.->|"冷恢复时由消息合成事件"| P
```

## 事件与事件存储

一条事件有八个字段：`id`、`sessionId`、`turnId`、`type`、`timestamp`、`traceId`、`sequenceNumber` 与 `payload`（`apps/zcode-cli/packages/contracts/src/events/session.events.ts:72`）。`payload` 的类型是 `unknown`，消费方按 `type` 自行断言。工厂函数创建事件时序号一律为 0（`session.events.ts:1133`），真正的序号由事件存储在 `append` 时分配（`apps/zcode-cli/packages/contracts/src/events/in-memory-session-event-store.ts:61`）。

事件存储的接口只有五个必选方法：追加、全量读、按序号之后读、取最新序号、删除会话（`session.port.ts:57`）。仓库里唯一的实现是进程内的 `InMemorySessionEventStore`（`in-memory-session-event-store.ts:33`），协议服务为每条会话记录新建一个（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:239`、`server-operations.ts:3300`）。它的类注释说明了定位（`in-memory-session-event-store.ts:26`）：它是实时推送、重放与快照三者序号的唯一来源，但只驻留在内存里。事件因此不跨进程存活，重启之后能恢复的只有 SQLite 里的消息与条目。

长会话里流式事件与 token 同频，全部留着内存会线性增长，所以存储按“回合窗口”淘汰四种瞬态事件（`apps/zcode-cli/packages/contracts/src/events/session-event-retention.ts:4`）：

```ts
/**
 * 与消息流同频的瞬态事件。
 * 它们仍经 event store 分配 seq 并交给 live sink，但 turn 结束并被下一 turn 取代后即可从内存淘汰：
 * 已完成 turn 的文本由持久化消息重新合成，reducer / rewind / fork / checkpoint 不消费这些类型。
 */
export const TRANSIENT_SESSION_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set([
  SessionEventType.ModelStreaming,
  SessionEventType.ToolCallProgress,
  SessionEventType.StreamingToolLedgerUpdated,
  SessionEventType.ModelNetworkStatus,
]);
```

回合收到 `turn_complete` 或 `turn_error` 只是被标记为已封口，等下一个 `turn_started` 到来才一次性淘汰；滞后一个回合，是因为刚结束的回合消息可能还没落盘（`session-event-retention.ts:49`）。子 Agent 这种只有一个回合的会话等不到下一回合，就靠 60 秒一次的采样节拍兜底，封口超过 120 秒即淘汰（`session-event-retention.ts:22`、`server.ts:298`）。序号计数器只增不减，淘汰不会造成重复序号（`in-memory-session-event-store.ts:65`）。

## 事件类型谱系

`SessionEventType` 一共 79 种（`session.events.ts:83`），按用途归纳：

| 类别 | 事件（行号均在 `session.events.ts`） | 主要产生方 |
| --- | --- | --- |
| 会话 | `session_created`、`session_resumed`、`session_forked`、`session_compacted`、`session_title_updated`、`session_mode_changed`、`session_ended`（84–90） | core；`session_created` 只在冷恢复时由协议层合成 |
| 回合 | `turn_started`、`turn_complete`、`turn_error`、`turn_input_received`（91–92、109–110） | core |
| 输入队列 | 七个 `turn_steer_*` 与 `session_input_promoted`、`queue_auto_drain_changed`、`followup_mode_changed`（93–108） | core，见[输入受理](https://daiw.org/manual/zcode/prompt-admission) |
| 消息 | `user_message`、`assistant_message`、`assistant_feedback_updated`、`system_message`（111–114） | 只有反馈由协议层产生 |
| 模型 | `model_request`、`model_selected`、`model_streaming`、`model_complete`、`model_error`、`model_network_status`、`model_anomaly_warning`、`network_request_status`、`streaming_tool_ledger_updated` 与六个 `stream_recovery_*`（115–129） | core，见[一次模型请求](https://daiw.org/manual/zcode/model-step) |
| 工具 | `tool_call_scheduled`、`_started`、`_progress`、`_result`、`_error` 与 `tool_batch_complete`（130–135） | 工具执行器 |
| 后台与工作流 | 三个 `background_task_*` 与 `dynamic_workflow_run_progress`（136–143） | core |
| 权限与交互 | `permission_requested`、`_resolved`、`_denied` 与 `user_input_auto_resolution_updated`（144–147） | core |
| 钩子 | 四个 `workspace_hook_*` 与五个 `hook_run_*`（148–157） | 工作区钩子审阅由 bootstrap 发，钩子运行由 HookRunner 发 |
| 压缩 | `compact_started`、`_completed`、`_failed` 与 `compact_boundary`、`microcompact_boundary`（158–162） | core，见[上下文压缩](https://daiw.org/manual/zcode/compaction) |
| 回退与检查点 | `rewind_triggered`、`checkpoint_created`（163–164） | core |
| 目标 | `target_changed`、`target_completion_verification`（165–166） | core |
| 子 Agent | `subagent_spawned`、`subagent_message`、`subagent_stopped`（167–169） | core |
| 控制 | `interrupt`、`cancel`、`resume`、`error`（170–173） | 无 |

不少类型只剩定义。按 `SessionEventType.X` 全仓搜索，`turn_input_received`、`user_message`、`system_message`、`session_compacted`、`session_ended`、`model_error`、`hook_run_progress`、`stream_recovery_blocked` 与四个控制类事件都找不到产生方，只有少数消费方还留着对应的分支；`assistant_message` 只在 CLI 登录流程里临时拼一条给 TUI 显示（`apps/zcode-cli/packages/cli/src/command-center/login-flow.ts:141`）。换句话说，消息内容并不走事件，它走下面讲的持久化写入。

## appendEvent 的四步

所有事件都从 `appendEvent` 出去（`apps/zcode-cli/packages/core/src/runtime/methods/events.ts:86`）：

```ts
  // live sink 以前拿到的是 createSessionEvent 默认的 sequenceNumber=0，
  // 而 replay/read 路径拿到的是 eventStore 补号后的事件，导致同一 session 有两套顺序事实。
  // 这里只发布已落库事件，让 live、replay、snapshot 的 eventSeq 全部来自同一个 event store。
  // ...
  let phase = "event_store.append";
  try {
    const storedEvent = await this.eventStore.append(event);
    phase = "session_event.persist_durable";
    await persistDurableSessionEvent.call(this, storedEvent, traceContext);
    phase = "session_event.record_usage";
    await recordToolUsageFromEvent(this, storedEvent, traceContext);
    phase = "session_event.notify_sinks";
    await this.notifyEventSinks(storedEvent, traceContext);
```

第二步是事件到持久层的一小段投影，只处理七种事件（`events.ts:263`）。注释解释了为什么放在事件汇统一处理：像 `turn_steer_queued` 这类事件有五个以上的发射点，单点接线才保证不漏（`events.ts:316`）：

| 事件 | 写到哪里 |
| --- | --- |
| `checkpoint_created` | 会话条目 `runtime/workspace_checkpoint`（`events.ts:270`） |
| `rewind_triggered` | 会话条目 `runtime/workspace_file_rewind`（`events.ts:275`） |
| `user_input_auto_resolution_updated` | 会话条目 `runtime/user_input_auto_resolution`，按交互 id 覆写（`events.ts:280`） |
| `turn_steer_queued` | 输入账本登记为 `admitted`（`events.ts:320`） |
| `turn_steer_delivery_changed` | 账本改投递方式（`events.ts:357`） |
| `turn_steer_discarded` | 账本收口为 `cancelled` 或 `discarded`，原因是 `promoted` 时不动（`events.ts:386`） |
| `target_completion_verification` | 会话条目，外加一条目标校验分隔线（`events.ts:420`） |

这些写入失败都只记警告，不打断事件发布。第四步依次等待每个订阅者，单个订阅者抛错只记日志（`events.ts:528`）。日志也按频率区别对待：回合开始、完成、出错与标题更新这类生命周期事件记 info（`events.ts:44`），流式与进度类事件则按回合聚合，每满 100 条记一条摘要（`events.ts:33`）。

## EventReducer：会话投影

核心 reducer 住在 contracts 包（`apps/zcode-cli/packages/contracts/src/events/event-reducer.ts:74`），经 `deps.ts` 再导出给 core，每个 runtime 构造时各建一个（`apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:255`）。它是一张以事件类型为键的处理表，覆盖 30 种事件，其余类型只更新 `updatedAt`（`event-reducer.ts:87`）。投影结果 `SessionProjection` 是一份“此刻的会话状态”（`session.port.ts:84`）：状态、回合数、token 累计与上下文占用、待审批请求、排队输入、进行中的工具调用、后台任务、最近一次压缩、检查点、回退与错误、当前目标与完成校验记录。

`rebuildProjection` 每次都从头归约该会话的全部事件（`apps/zcode-cli/packages/core/src/runtime/methods/message-persistence.ts:385`）。它的主要用户是队列操作：回合结束后排队输入只剩投影里的 `pendingSteerInputs`，编辑、删除、重排都要先查它。回合结束时的 `TurnResult` 也附上一份，bootstrap 观察子 Agent 时读父会话的投影（`apps/zcode-cli/packages/bootstrap/src/app/subagent-observation.ts:71`）。有个细节保证了旁路请求不污染主会话：`model_complete` 只有在 `querySource` 为 `main_turn` 时才更新上下文占用（`event-reducer.ts:60`）。

这个 reducer 不生产消息，也不生产 part。界面上看到的对话行来自协议层的“第二个 reducer” `ProductProjection`（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:2`），它按事件类型把事件归约成界面增量（`product-projection.ts:1313`）。冷恢复时事件早已不在内存，协议层反过来把 SQLite 里的消息合成一串等价事件，再喂给同一个 reducer，文件头把这个原则写成“reduce(transcript) ≡ reduce(events)”（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/transcript-hydration.ts:1`）。两层投影的细节归 [ZCode Protocol V4](https://daiw.org/manual/zcode/zcode-protocol)。

## 消息与 part：和事件并排落库

消息与 part 不是从事件推导出来的，而是由运行时在同一段代码里与事件并排写出，最终落到 `saveMessage` 与 `savePart`（`message-persistence.ts:336`、`message-persistence.ts:354`）。part 共 13 种（`apps/zcode-cli/packages/contracts/src/interfaces/session-store.port.ts:764`），与 [OpenCode 的 12 种 Part](https://daiw.org/manual/opencode/session-model) 一一对应，另多一种 `timeline`。几类写入的时机：

| 写入 | 时机 | 要点 |
| --- | --- | --- |
| 用户消息与 text、file part | 回合开始、guide 注入 | `persistUserPrompt`（`message-persistence.ts:29`）；带账本 id 时与“已提升”同一事务，提交后才发 `session_input_promoted`（`message-persistence.ts:142`） |
| 助手消息 | 每个模型步 | `persistAssistantMessage`（`message-persistence.ts:270`）；记录实际生成它的 provider 与 model、`cwd` 与 `root`、token 用量 |
| 工具 part | 模型声明调用、批次开跑、结果返回 | 同一个 part id 依次写成 `pending`、`running`、`completed` 或 `error`（`apps/zcode-cli/packages/core/src/runtime/methods/tool-part-persistence.ts:30`、`apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:198`、`turn-tools.ts:302`） |
| timeline part | 切换模型、压缩、目标校验、分叉 | 挂在一条 `timeline_event` 语义的助手消息上，界面可见、模型不可见（`apps/zcode-cli/packages/core/src/runtime/methods/timeline-persistence.ts:86`） |
| 合成通知 | 回退、后台结果、子 Agent 回话、目标续跑等 | 见下一节 |

工具 part 完成时的元数据带 schema 版本、展示载荷、序列化信息（是否截断、原始与返回字节数、落盘路径），以及模型当时读到的文件快照；注释说恢复会话时要据此重建“已读文件”状态，不能依赖工具结果的文本格式（`apps/zcode-cli/packages/core/src/runtime/methods/tool-part-metadata.ts:19`、`tool-part-metadata.ts:37`）。模型把工具名写成空串时，part 里记为 `empty_tool_name`，原名放进元数据（`tool-part-persistence.ts:12`）。

每条消息还有两套标注。`semantics` 回答“这是什么”：来源（真实用户、运行时、系统、迁移、导入）、种类，以及界面、模型、transcript 三个面上各自是否可见（`session-store.port.ts:91`）。`anchor` 是给 v4 投影用的锚点：回合 id、输入来源与命令 id，以附加 JSON 的方式演进，旧数据没有也能读（`apps/zcode-cli/packages/core/src/runtime/methods/projection-anchor.ts:8`）。

模型切换的分隔线是延迟写的：切换时只在内存里记一笔，连续切换会合并，切回原模型则抵消（`timeline-persistence.ts:15`、`timeline-persistence.ts:27`），等下一个回合开始才落成 timeline part（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:476`）。

## 合成通知

运行时经常要以 user 角色往对话里塞一段上下文：回退说明、后台任务结果、子 Agent 的回话、目标续跑提示、插件引用提醒等。它们统一经 `persistSyntheticUserNoticeForSession` 落库，标记 `synthetic: true`，缺省只给模型看（`message-persistence.ts:198`）。来源共 12 种（`session-store.port.ts:51`），语义标注按可见性推出（`apps/zcode-cli/packages/core/src/runtime/methods/synthetic-notice-metadata.ts:46`）：

```ts
  const providerVisible = visibility === "model-only";
  return {
    origin: "agent_runtime",
    kind: syntheticUserNoticeKind(source),
    source,
    uiVisibility: providerVisible ? "hidden" : "visible",
    providerVisibility: providerVisible ? "visible" : "hidden",
    // provider-visible 的 user-role runtime context 不是用户 transcript。
    // fork/goal/background 等 synthetic notice 如果标成 transcript visible，
    // v4 hydration 会把它们误当作真实用户 turn。
    transcriptVisibility: providerVisible ? "hidden" : "visible",
  };
```

各来源的 `kind`（`synthetic-notice-metadata.ts:60`）：

| 来源 | kind |
| --- | --- |
| `background_task` | `background_notification` |
| `subagent`、`subagent_message` | `subagent_notification` |
| `goal-continuation`、`goal_state_change`、`plugin_reference`、`selection_side_chat` | `system_reminder` |
| `fork`、`rewind`、`todo_reminder` | `fork_notice`、`rewind_notice`、`todo_reminder` |
| `shared_context` | `shared_context`：来源记为导入，界面隐藏，模型与 transcript 可见 |
| `workflow_launch` | `user_prompt`：只为穷举登记，实际另有专用落盘，见[控制类回合](https://daiw.org/manual/zcode/prompt-admission) |

锚点里的来源另有一张映射：后台任务与子 Agent 记为 `backgroundResult`，目标续跑记为 `goalContinuation`，其余一律 `synthetic`（`projection-anchor.ts:27`）。

## 会话标题

标题有四种来源：`default`、`first_input`、`generated`、`custom`（`session-store.port.ts:45`）。会话第一次落库时，标题取首条输入压缩空白后的前 60 个字符，超长截到 57 个字符加省略号，空输入记为“Untitled session”（`apps/zcode-cli/packages/core/src/runtime/helpers/project.ts:4`），并立刻补发一条 `source: "first_input"` 的 `session_title_updated`。注释说之前只写库不发事件，侧边栏会一直显示“New session”，直到模型生成的标题回来（`events.ts:622`）。用户重命名则写成 `custom`（`apps/zcode-cli/packages/core/src/runtime/methods/session-title.ts:264`），此后自动标题再也不会覆盖它。

模型生成的标题在首条用户消息落库之后立即发起，不等主回合结束，这样即使用户中途停止首轮，标题请求也已经发出（`turn.ts:530`）。例外是需要在请求前刷新运行时请求头的 provider：标题请求会先占用刷新窗口、让主消息失败，所以推迟到回合结束后再发（`turn.ts:677`）。能否发起由一组条件决定（`session-title.ts:138`）：

```ts
function shouldAttemptSessionTitleGeneration(
  runtime: AgentRuntimeInternal,
  input: string,
  options: { bypassShortInputGuard?: boolean } = {},
): boolean {
  if (runtime.sessionTitleGenerationAttempted) return false;
  if (runtime.config.titleGeneration?.enabled === false) return false;
  if (!runtime.config.titleGeneration) return false;
  if (!runtime.sessionStore) return false;
  if (runtime.config.parentSessionId) return false;
  if (runtime.config.taskType && runtime.config.taskType !== "interactive") return false;
  if (runtime.turnNumber !== 0) return false;
  const normalizedInput = normalizeTitleInput(input);
  if (normalizedInput.length === 0) return false;
  // 短首发输入本身已经是可读标题，继续走 generated title sidecar
  // 会把 "hi" 这类标题稳定覆盖成泛化的 "New Coding Session"。
  return (
    options.bypassShortInputGuard ||
    Array.from(normalizedInput).length >= MIN_GENERATED_TITLE_INPUT_CHARS
  );
}
```

`MIN_GENERATED_TITLE_INPUT_CHARS` 是 10（`session-title.ts:27`）。`/goal` 走控制类回合，调的也是这个普通入口（`apps/zcode-cli/packages/core/src/runtime/methods/control-only-turn.ts:241`）；另有一个跳过长度门槛的外部入口，注释说是为 `/goal` 这类命令准备的，但仓库里找不到调用方（`session-title.ts:59`）。`titleGeneration` 配置缺席等于关闭，TUI 与协议会话都显式传入空对象来开启（`apps/zcode-cli/packages/cli/src/tui-command-state.ts:35`），自动化任务的会话则传 `enabled: false`，免得回答内容覆盖原始问题当标题（`server-operations.ts:3354`）。

所谓 sidecar，是一次挂在主回合旁边、独立于主回合生命周期的模型请求（`apps/zcode-cli/packages/core/src/runtime/methods/title-generation-sidecar.ts:86`）：

- **模型**：`titleGeneration.modelSelection`，缺省用会话当前的模型；再绑定辅助调用参数，推理档位取最低一档，输出上限取 5000 与模型上限中的较小者（`apps/zcode-cli/packages/core/src/model/auxiliary-model-options.ts:9`）。
- **请求**：一条系统提示词加一条用户消息，输入截到 1200 个字符，不给任何工具，超时 60 秒（`title-generation-sidecar.ts:25`）。系统提示词强调这是命名任务而不是对话，要求只回 `{"title":"..."}`，开头一句是 “Generate a concise title for this coding session.”（`title-generation-sidecar.ts:31`）。bootstrap 入口层也补了一个 60 秒的缺省，注释说旧的 15 秒在慢模型或代理链路下容易超时（`apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:78`）。
- **观测**：它照样发 `model_request` 与 `model_complete` 事件，`querySource` 记为 `session_title`，也照样记用量（`title-generation-sidecar.ts:113`、`title-generation-sidecar.ts:172`）；遥测是单独的后台 trace，入队时冻结触发它的 span 作为因果链接（`session-title.ts:100`）。
- **清洗与写回**：去掉思考标签，优先解析 JSON，也认 Markdown 代码块包着的 JSON，再去掉首尾引号与句末标点，最长 100 个字符（`title-generation-sidecar.ts:231`）。写回时带上“期望来源仍是 default、first_input 或 generated”的条件，期间被用户改名就放弃（`session-title.ts:313`）；首条问题已被编辑也放弃（`session-title.ts:377`）。

第一次设置目标时，同一个 sidecar 的结果还会兼作目标的摘要标题；失败或没有资格生成时，目标摘要退回到截断后的目标原文（`apps/zcode-cli/packages/core/src/runtime/methods/goal-summary-title.ts:13`），目标本身见[目标模式](https://daiw.org/manual/zcode/goal-target)。

## 会话驻留

协议服务一个进程要托管许多会话，每个活着的会话都占着一个 `ZCodeApp`、一个 runtime、一个内存事件存储和可能的 MCP 连接。常驻池负责把不再需要的会话从内存里请出去：默认目标保留 8 个、上限 16 个，连续空闲 10 分钟即可回收（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-resident-pool.ts:7`）。回收先按空闲超时，超过上限时再按最近使用时间淘汰到目标数；每次动手前都重新读一遍事实，只有已落库、没有在飞工作、没有待回答的交互、没有排队命令、没有订阅者的会话才有资格（`session-resident-pool.ts:226`）。回收做的是摘除记录、关闭 app、清空该会话的内存事件，注释说去激活之后必须与“从未加载”等价（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-residency.ts:53`）；下次打开时从 SQLite 冷恢复。

难点在“在飞工作”怎么判定。标题 sidecar、MCP 启动、账本写入这些脱离调用栈的 Promise，既不算活动回合，也不在后台任务表里，常驻池过去看不见它们，会把仍在读写 runtime 的会话当成空闲关掉。runtime 于是自己维护一个计数（`apps/zcode-cli/packages/core/src/runtime/methods/residency.ts:10`）：

```ts
export function trackResidencyBlockingWork<T>(
  this: AgentRuntimeInternal,
  work: Promise<T>,
): Promise<T> {
  this.residencyBlockingWorkCount += 1;
  return work.finally(() => {
    this.residencyBlockingWorkCount = Math.max(0, this.residencyBlockingWorkCount - 1);
  });
}

/** Session 常驻池只消费这一项，新增 sidecar 时不再修改 bootstrap 的猜测列表。 */
export function hasResidencyBlockingWork(this: AgentRuntimeInternal): boolean {
  return (
    this.hasActiveOrQueuedTurnWork() ||
    this.hasRunningBackgroundTasks() ||
    this.residencyBlockingWorkCount > 0 ||
    (this.memoryExtractionScheduler?.hasPendingWork() ?? false)
  );
}
```

计数必须在 Promise 启动的同一同步片里增加（`residency.ts:8`）。登记的地方有标题与目标摘要 sidecar（`session-title.ts:126`、`goal-summary-title.ts:62`）、MCP 启动（`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:111`）、后台通知的账本写入（`apps/zcode-cli/packages/core/src/runtime/methods/background-notifications.ts:73`）、目标回合的心跳（`turn.ts:347`），以及 bootstrap 里在飞的动态工作流引擎。公开接口上的注释记下了最后这个的来由：工作流引擎跑在会话 app 里、不进任务表，曾出现“引擎在飞时会话被按 idle 关掉”，从此约定新增的旁路工作一律到这里登记（`agent-runtime.ts:402`、`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:697`）。协议层读驻留事实时，把这一项与自己的命令收尾计数合在一起（`session-residency.ts:85`）。

下一篇：[系统提示词、上下文与提醒](https://daiw.org/manual/zcode/context-builder)——每次请求模型之前，系统提示词由哪些段落拼成，各类提醒在什么时机注入。
