# V2 会话内核

> 持久化收件箱把提示的受理与执行拆开，协调器按会话串行调度，SessionRunner 每轮只发一次 llm.stream，再加上 Context Epoch 与自动压缩。

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

V1 的会话主循环是 `SessionPrompt` → `LLM.run` → `SessionProcessor`（见[主循环](https://daiw.org/manual/opencode/prompt-loop)与[流处理器](https://daiw.org/manual/opencode/processor)）。V2 的内核在 `packages/core/src/session/`（约 3700 行）和门面 `packages/core/src/session.ts` 里，还没有取代 V1：规格 `specs/v2/session.md` 写明 `session.next.*` 事件仍是实验性的，早期实验库可以丢弃，迁移 `20260622170816_reset_v2_session_state` 就直接清空了 V2 的几张表。runner 开头的注释写道：

> Keep this as orchestration over smaller collaborators rather than rebuilding the legacy `SessionPrompt` monolith.

## 怎么用

V2 会话只通过新协议暴露：`packages/protocol/src/groups/session.ts` 在 `/api/session` 下定义了创建、`prompt`、`interrupt`、`history`（分页读持久事件，默认每页 50 条）、`event`（先重放、后追尾的事件流）等接口，新 CLI 的守护进程与旧 `opencode` 的 HTTP 服务都挂载了这组路由。门面上的 `shell`、`skill`、`compact`、`wait` 目前一律返回 `OperationUnavailableError`。

## 受理：先落盘，再谈执行

`prompt` 不直接跑模型，只做“受理”：

```ts
            const delivery = input.delivery ?? "steer"
            const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
            const admitted = yield* SessionInput.admit(db, events, {
              // ...
            }).pipe(
              // ...
            )
            if (!SessionInput.equivalent(admitted, expected))
              return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
            if (input.resume !== false) yield* execution.wake(admitted.sessionID)
            return admitted
```

`packages/core/src/session.ts:366`（删减后）。`SessionInput.admit` 发布持久事件 `PromptAdmitted`，投影器把它写成 `session_input` 表的一行，`admitted_seq` 就是该事件在会话聚合里的序号。此时输入对模型不可见；之后串行的 runner 在安全边界发布 `Prompted`，其投影器在同一个事件事务里写入可见的用户消息，并给收件箱行填上 `promoted_seq`。

幂等规则全落在消息 ID 上：精确重试（同会话、同提示、同投递方式）返回同一张回执并再唤醒一次；同一个 ID 撞上别的会话、提示或投递方式，或已是可见消息，都报 `PromptConflictError`；`resume: false` 只受理不执行。好处是调用方拿到回执即算提交成功，模型失败或进程重启都不会丢输入，排队中的输入也能被客户端观察到。

## 执行：同会话串行，跨会话并发

执行只认会话 ID。本地实现 `session/execution/local.ts` 先查 `SessionStore`，再用 `locations.get(session.location)` 进入会话所在的 Location（见[上一篇](https://daiw.org/manual/opencode/effect-runtime)），调用那里的 `SessionRunner.run`。调度交给 `run-coordinator.ts`，它为每个会话维护一个条目：

| 操作 | 会话空闲时 | 已在执行时 |
| --- | --- | --- |
| `run`（显式续跑） | 强制起一次 drain | 加入并等待当前执行 |
| `wake`（有新输入） | 起一次普通 drain | 只记一个 `pendingWake`，多次合并 |
| `interrupt` | 无操作 | 标记停止、清掉 `pendingWake`、中断纤程；收件箱行保留 |

drain 结束时若有 `pendingWake`，协调器立刻接力再跑一轮。`active()` 就是这张表的键集合，只代表本进程的前台 drain，重启后即为空。

## SessionRunner：每轮一次 llm.stream

外层循环如下：

```ts
      let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
      let shouldRun = input.force || hasSteer || hasQueue
      while (shouldRun) {
        let needsContinuation = true
        let step = 1
        while (needsContinuation) {
          const result = yield* runTurn(input.sessionID, promotion, step)
          needsContinuation = result.needsContinuation
          step = result.step + 1
          promotion = "steer"
          if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
        }
        shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue")
        promotion = shouldRun ? "queue" : undefined
      }
```

`packages/core/src/session/runner/llm.ts:398`。steer（默认）在下一个安全的 provider 轮次边界提升，哪怕当前 drain 还要续跑；queue 只在会话本来要闲下来时提升，一次一条，提升后重新评估是否续跑；提升了新输入，步数重置为 1。每一轮 `runTurnAttempt` 依次：

1. **位置栅栏**：会话已被移到别的 Location 就直接中断。
2. **准备上下文**：选定 agent，初始化或对账 Context Epoch，按序提升输入。
3. **组装请求**：system 是 agent 提示加 epoch 基线，历史经 `to-llm-message.ts` 转成 `@opencode-ai/llm` 消息；到达 agent 的 `steps` 上限时追加 `max-steps.ts` 的提示并设 `toolChoice: "none"`。
4. **压缩检查**，然后只调用一次 `llm.stream(request)`。
5. **急切执行工具**：每个完整的本地工具调用先持久化，再立即在 `FiberSet` 里启动；流结束后等全部结算，发布带文件差异的 `Step.Ended`，下一轮重新加载历史。

`publish-llm-event.ts` 把 `LLMEvent` 流翻译成持久的 `session.next.*` 事件，发布经信号量串行化。两条安全规则：开始 drain 前，`failInterruptedTools` 把上个进程遗留的 pending、running 工具统一标记为 `Tool execution interrupted`，副作用绝不静默重放；用户拒绝权限或问题时循环直接停下，与 V1 一致。`runner/model.ts` 目前只支持 `@ai-sdk/openai`、`@ai-sdk/anthropic` 与配置了 URL 的 `@ai-sdk/openai-compatible` 三类端点，分别走 [packages/llm](https://daiw.org/manual/opencode/llm-package) 的 OpenAI Responses、Anthropic Messages 与 OpenAI 兼容 Chat 协议。

## Context Epoch 与自动压缩

V2 把模型看到的特权系统上下文持久化在 `session_context_epoch` 表：不可变的基线文本（照顾 provider 前缀缓存）、模型不可见的结构化快照、基线对应的事件序号。上下文由若干带 key 的 Source 组成（`packages/core/src/system-context/`）：内置的 `core/environment`、`core/date`，`core/instructions`（全局与向上查找到的 AGENTS.md），再加 skill 指引与引用指引。

每轮对账时，来源没变则跳过；变了就发布一条按时间顺序插入历史的 `ContextUpdated` 系统消息，并在同一事务里推进快照；来源暂时观测不到就保留旧值；首次初始化遇到不可用的来源报 `InitializationBlocked`，提示保持待处理。规格还要求会话换 Location 时清空 epoch，`SessionContextEpoch.reset` 已写好，但 v1.18.30 里还没有调用方。

`compaction.ts` 在组装好请求后估算体积，超过“上下文窗口减去输出上限与 `compaction.buffer`（默认 20000）中的较大者”就先压缩：较早的对话按固定的五段模板摘要，保留最近约 8000 token 的原文。只有持久事件 `Compaction.Ended` 投影出模型可见的压缩消息，失败时旧边界照常生效；压缩完成后下一轮直接渲染新基线。provider 在尚无助手输出时报上下文溢出，会额外做一次溢出压缩并重建同一轮，再溢出即按普通失败结束。对照见[上下文压缩](https://daiw.org/manual/opencode/compaction)。

## 读模型与 V1 桥接

`projector.ts` 用 `events.project` 为每种事件注册投影：V1 事件写 `session`、`message`、`part`，V2 事件写 `session_message`；`message-updater.ts` 用 immer 把增量事件合并进当前助手消息；`history.ts` 按最近的压缩点与 `baseline_seq` 选出给 runner 的消息行。

两代共用 `session` 表，V2 的 `create` 发布的仍是 `SessionV1.Event.Created`（V1 契约留在 `packages/core/src/v1/`）。规格提到 V1→V2 影子桥会为已可见的 V1 提示补发 `Prompted`，但 v1.18.30 的 `packages/opencode/src` 里找不到发布方，只有 `SessionInput.projectPrompted` 为“没有收件箱行的 `Prompted`”保留了补插分支。

规格的 V1 运行时上下文对齐清单共 21 项：complete 3 项、partial 8 项、missing 10 项，配置与远程指令、provider 专属基础提示、插件变换、结构化输出等仍未实现；崩溃后续跑恢复、provider 超时与重试、集群所有权也被明确延后。

下一篇：[V2 工具与权限](https://daiw.org/manual/opencode/tools-v2)——单一执行体的工具契约与默认 ask 的权限模型。
