# 回合系统：受理、排队与执行

> 一次提交如何经过受理、入队、派发、执行与收尾；运行中 Enter 的“引导”与 Alt+Enter 的“排队”在代码里有何不同；租约、中止与错误重试的数字。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.net/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.net/manual/minimax-code/turn-system
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

会话系统管“存”，回合系统管“跑”：一条消息能不能跑、什么时候跑、谁持有这个会话、跑完怎样收尾。它住在 `packages/local-runtime-v2/src/service/turn-system`，四万多行，对外只暴露一个 `TurnService`（`packages/local-runtime-v2/src/service/turn-system/contracts.ts:510`）：

```ts
/** Stable consumer-facing TurnSystem capability. */
export interface TurnService {
  fork(input: HistoryForkInput): Promise<HistoryForkResult>;
  rewind(input: HistoryRewindInput): Promise<HistoryRewindResult>;
  submit(input: SubmitTurnSubmission): Promise<SubmitTurnResult>;
  resumeUserInput(input: ResumeUserInputSubmission): Promise<ActivateTurnResult>;
  inspectContinuation(sessionId: string): Promise<InspectTurnContinuationResult>;
  continueTurn(input: ContinueTurnInput): Promise<ContinueTurnResult>;
  steer(input: SteerSessionInput): Promise<SteerSessionResult>;
  abort(input: AbortTurnInput): Promise<AbortTurnResult>;
  requestCompaction(input: RequestCompactionInput): Promise<RequestCompactionResult>;
  /** Explicit user command: wake FIFO Queue draining without resuming a persisted Turn. */
  dispatchSessionQueue(sessionId: string): Promise<QueueSteerDispatchResult>;
  /** Internal Queue mutation/release wake used by compatibility composition. */
  dispatchQueue(sessionId: string): Promise<void>;
  sessionDeletion(sessionId: string, cleanup: () => Promise<void>): Promise<void>;
}
```

目录分工如下。压缩与失控保护也在这里，但分别留给[上下文管理与压缩](https://daiw.net/manual/minimax-code/context-management)和[Agent 模块](https://daiw.net/manual/minimax-code/agent-modules)。

| 位置 | 职责 |
| --- | --- |
| `turn-submission.service.ts` | 立即执行还是入队 |
| `execution/` | 受理与启动、进程内所有权与引导缓冲（`turn-controller/`）、执行与结算、引导（`steering/`） |
| `queue.dispatcher.ts`、`queued-turn-completion.ts` | 队列派发，以及把入队消息接回它的完成回调 |
| `persistence/` | 租约与受理回执、重启回收、插件钩子会话、大工具输出落盘 |
| `lifecycle/` | 进程内会话闸门、删除、历史分叉与回退 |
| `agent-host/` | 回合里真正驱动 Agent 循环的一层 |

## 怎么用

| 操作 | 按键或命令 | 走到运行时的调用 |
| --- | --- | --- |
| 空闲时发送 | `Enter` | `sendMessage`，最终 `submit` 且 `allowQueue: false` |
| 运行中调整当前响应 | `Enter`（`run.submit-guidance`） | `steer` |
| 运行中排到下一轮 | `Alt+Enter`（`run.queue-draft`） | `enqueueMessage`，最终 `submit` 且 `allowQueue: true` |
| 取回最近一条排队消息 | `Alt+Up` 或 `Shift+Left` | 删除该队列条目，内容放回输入框 |
| 中断当前回合 | `Esc` | `abortSession`，原因 `user_stop` |
| 显式引导 | `/steer <message>` | `steer`，空闲时会直接开一个新回合 |
| 管理队列 | `/queue` | 列出、修改、删除队列条目 |
| 重试失败的回合 | `/retry` | 重发失败的那次提交，或发空内容加 `retry-continuation` 意图 |
| 手动压缩 | `/compact [instructions]` | `requestCompaction` |

按键定义在 `packages/tui/src/tui/shell/keybindings.ts:352`（引导与排队只在 `live-run` 状态生效，取回在 `keybindings.ts:369`）与 `keybindings.ts:274`（`Esc`）；命令见 `packages/tui/src/tui/commands/catalog.ts`，其中 `/steer`（`catalog.ts:341`）、`/queue`（`catalog.ts:492`）、`/retry`（`catalog.ts:246`）、`/compact`（`catalog.ts:270`）。几个容易忽略的行为：

- 空闲时只要队列里还有没暂停的条目，`Enter` 也按入队处理，保证先来后到（`packages/tui/src/tui/controller/product/command-flow.ts:909`）；直接发送被运行时以“会话忙”“正在压缩”“有更早的排队消息”拒绝时，TUI 自动改走入队（`packages/tui/src/tui/controller/chat-controller-support.ts:131`）。
- `Esc` 中止后如果队列里还有条目，队列会暂停。此时再发消息，TUI 先问保留还是清空暂停的队列，再带着 `pausedQueueAction` 重发（`packages/tui/src/application/run-coordinator.ts:128`）。
- `/retry` 在模型请求正自动重试时只给一句提示，有被阻塞的 Goal 时先恢复 Goal；否则看这个进程有没有记下失败的那次提交，有就把原内容和附件重新提交一遍，没有才发空内容并带 `retry-continuation` 意图（`command-flow.ts:1304`）。失败记录只存在内存里（`command-flow.ts:172`），所以重启后回到出错的会话，走的是后一条路。空内容会被应用层换成一条隐藏的系统提醒（`packages/local-runtime-v2/src/application/conversation/conversation-application.ts:871`、`583`），开头一句是（`packages/local-runtime-v2/src/service/turn-system/agent-host/preparation/config/prompt-templates.ts:7`）：

> The previous assistant turn did not deliver a completed response (aborted, errored, or stalled while streaming).

## 一次提交的一生

```mermaid
flowchart TD
  S["TurnService.submit"] -->|"allowQueue: false"| M["消息投递"]
  S -->|"allowQueue: true"| Q["写入队列表"]
  Q --> D["QueueDispatcher 认领"]
  D --> M
  M --> X["产品准备 + SQLite 受理事务"]
  X -->|"拒绝"| R["返回原因，或把条目放回队列"]
  X -->|"受理"| C["TurnController 注册，持有租约"]
  C --> H["ExecutionCoordinator 调 AgentHost.run"]
  H --> T["结算：写终态、释放、唤醒队列"]
  T -.-> D
```

**提交**。`packages/local-runtime-v2/src/service/turn-system/turn-submission.service.ts:50` 只做一件事，按 `allowQueue` 分流：

```ts
export function createTurnSubmissionService(
  options: TurnSubmissionServiceOptions,
): TurnSubmissionService {
  const makeTurnId = options.makeTurnId ?? (() => `turn_${randomUUID()}`);
  return {
    submit: (input) =>
      input.allowQueue
        ? submitQueued(options, input, makeTurnId)
        : submitImmediate(options, input, makeTurnId),
    resumeUserInput: (input) => resumeUserInput(options, input, makeTurnId),
  };
}
```

回合 ID 在提交时就定下，用户消息 ID 由 `turn:` 加回合 ID 推出（`turn-submission.service.ts:95`），同一回合 ID 的重复提交因此落到同一条消息上。入队分支先登记完成回调，再落库、尽力唤醒派发器，并算出前面还排着几件事（`turn-submission.service.ts:127`）；带输出格式、截止时间或回调的提交不许入队（`turn-submission.service.ts:182`），来源也必须在白名单里（`turn-submission.service.ts:445`）。

**消息投递**。两条分支最后都交给应用层提供的消息投递（`packages/local-runtime-v2/src/service/turn-system/initialize.ts:91`）。它的原则写在类注释里（`packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts:93`）：

> Commits visible user Messages only after durable Turn acceptance or steer consumption.

做法是一道“启动栅栏”：受理成功后先把用户消息写进展示层，再放开栅栏（`user-message-turn-delivery.ts:146`）；执行协调器在调用 AgentHost 前会先等这道栅栏（`packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts:96`）。被拒绝的提交因此不会在界面上留下一条孤零零的用户消息。

## 受理：一次 SQLite 事务

`TurnExecutionService.submit` 先进进程内的会话闸门（`packages/local-runtime-v2/src/service/turn-system/lifecycle/session-operation-gate.ts:27`）：普通提交可以并行进入，会话级修改要独占，删除则整个封住；进不去的提交直接被拒（`packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts:59`）。接着跑产品侧的提交准备，目前是 Plan 模式入口与 Goal 两个参与者，它们各有 `commit`、`rollback`、`compensate`，受理失败就回滚（`turn-execution.service.ts:329`，组合见 `packages/local-runtime-v2/src/services.ts:1030`）。然后才是持久化受理，在 `immediate` 事务里完成（`packages/local-runtime-v2/src/service/turn-system/persistence/turn.repository.ts:250`）。事务内的检查顺序是：会话是否可受理、是否正在删除、是否重复、是否已有活动回合、优先级栅栏、产品策略（`turn.repository.ts:706`），核心几步（`turn.repository.ts:731`）：

```ts
  const duplicate = findDuplicate(tx, input);
  if (duplicate) return withRecoveredTerminalFacts(duplicate, terminalFacts);
  const lockRejection = activeLockRejection(lock);
  if (lockRejection) return withRecoveredTerminalFacts(lockRejection, terminalFacts);
  if (
    !input.bypassPriorityFence &&
    options.priorityFence.blocksInTransaction(tx, {
      sessionId: input.sessionId,
      priority: input.priority,
    })
  ) {
    return withRecoveredTerminalFacts(
      { status: 'rejected', reason: 'priority-blocked' },
      terminalFacts,
    );
  }
  const policyRejection = options.admissionPolicy?.applyInTransaction(tx, input);
```

- **重复**：受理回执不存输入原文（`inputJson` 恒为 `'{}'`，`turn.repository.ts:1172`），只存摘要。同一回合 ID、同一 `clientRequestId` 或同一队列认领再次到来时，输入摘要与认领信息都一致判为 `duplicate`，否则判为 `ingress-conflict`（`turn.repository.ts:1120`）。
- **活动回合**：每个会话在 `local_runtime_session_locks` 里至多一行锁，已有锁就拒绝为 `active-turn`，压缩中则是 `compaction-active`（`turn.repository.ts:764`）。
- **优先级栅栏**：由会话系统实现。队列暂停时，除重试续跑、继续暂停队列、问卷续答等少数优先级外一律挡住；普通候选若比某个已排队条目来得晚，也被挡住（`packages/local-runtime-v2/src/service/session-system/queue/turn-priority-fence.ts:152`、`239`）。
- **产品策略**：Plan 模式与记忆召回两个策略在同一事务里依次判定，第一个拒绝即生效（`packages/local-runtime-v2/src/service/turn-system/admission-contracts.ts:60`，组合见 `services.ts:1026`）。

全部通过后插入锁行（默认租约 30 分钟，`turn.repository.ts:37`）、一个全局递增序号和一张受理回执（`turn.repository.ts:1139`）。拒绝原因（`admission-contracts.ts:5`）在应用层映射成错误键：`active-turn` 落到兜底的 `local_session_busy`，`priority-blocked` 是 `local_session_has_queued_messages`，队列暂停导致的拦截是 `local_session_queue_paused`（`packages/local-runtime-v2/src/application/conversation/session-stream-delivery.ts:412`）。手动压缩也走这套受理，只是锁的类型是 `compaction`，压缩期间的新提交会被拒为 `compaction-active`（`turn-execution.service.ts:581`）。

## 执行与收尾

受理之后，`startAcceptedTurn` 把回合注册进 `TurnController`（`turn-execution.service.ts:497`）。控制器为每个会话在内存里维护一个条目：中止信号、引导缓冲、续租定时器（`packages/local-runtime-v2/src/service/turn-system/execution/turn-controller/turn.controller.ts:68`）。续租每 10 秒一次，中止后最多等 5 秒让回合释放（`turn.controller.ts:20`）；续租失败或租约丢失，回合以 `lease-lost` 或 `lease-renewal-failed` 为由被中止（`turn.controller.ts:102`）。

执行协调器随后调用 `AgentHost.run`（`execution.coordinator.ts:121`）。`LocalAgentHost` 自称“Ordered, fail-closed adapter between Turn ownership and the Agent loop”（`packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts:116`）：取回合能力、预检、组装运行时、跑回合开始钩子，再交给执行器，最终调用 `PiTurnRunner.runTurn`（`packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts:274`）。回合内部怎样调用 pi，见[pi 的 Agent 循环与回合执行器](https://daiw.net/manual/minimax-code/pi-agent)。

回合结束后由 `settleAndRelease` 收尾（`execution.coordinator.ts:224`）：先把中止时没来得及消费的用户引导写进对话，再写终态（失败重试一次），然后从控制器注销、通知产品侧结算，最后发布“已释放”信号唤醒队列（`execution.coordinator.ts:264`）。只有正常完成的回合才发这个信号，停下来等用户回答的回合除外（由 Goal 发起的照发）；中止和失败的回合都不发（`execution.coordinator.ts:140`、`321`）。前台回合失败时队列以 `turn-final-failure` 暂停，用户主动停止则以 `user-stop` 暂停（`execution.coordinator.ts:297`），且只有队列里真有条目时才记暂停（`turn-priority-fence.ts:59`）。

## 队列派发

`QueueDispatcher`（`packages/local-runtime-v2/src/service/turn-system/queue.dispatcher.ts:69`）按会话串行排空：每次唤醒递增一个计数，正在跑的排空结束后若发现计数变了，就再来一遍（`queue.dispatcher.ts:81`）。唤醒来源主要有三处：入队后的尽力唤醒、回合正常完成后的“已释放”信号（`initialize.ts:111`）、启动时的恢复（`queue.dispatcher.ts:127`）。

每认领一条，依次做分类、取消检查、执行（`queue.dispatcher.ts:299`）。分类由产品注入：有待回答的问卷时推迟，Plan 与 Goal 各有栅栏；自主 Goal 条目堵在队首而后面有用户消息时，本轮让出队首位置（`packages/local-runtime-v2/src/application/session/runtime-queue-selection.ts:18`、`47`）。执行走和立即发送同一套消息投递，额外挂两个钩子：`beforeSubmit` 把认领绑定到回合 ID，`beforeStart` 在 AgentHost 启动前确认受理、删除队列行（`queue.dispatcher.ts:361`）。

被拒绝时怎么处理，看原因（`queue.dispatcher.ts:583`）：`invalid-session`、`ingress-conflict`、`invalid-input` 删行，其余原因（包括 `active-turn`）只把认领释放回去。所以运行中按 `Alt+Enter`，入队后那次尽力派发会因为 `active-turn` 被拒、条目原样留下，等当前回合正常完成、发出“已释放”信号后再被认领。重新投递的条目会换一个新回合 ID，并带上此前尝试过的回合 ID（`queue.dispatcher.ts:649`）。入队方拿到的完成回调由 `QueuedTurnCompletionRegistry` 接回：条目被并入别人的回合算完成，被取消或被取回输入框算中止，其余移除原因算失败（`packages/local-runtime-v2/src/service/turn-system/queued-turn-completion.ts:112`）。

## Enter 与 Alt+Enter：引导和排队

两个键在 TUI 里只差一个标记：`busyAction` 是 `steer` 还是 `queue`（`packages/tui/src/tui/controller/interaction/input-flow.ts:366`）。`steer` 调 `activeRunFlow.steer`，要求当前必须有活动回合（`command-flow.ts:530`），最终的运行时调用是（`packages/tui/src/tui/controller/run/active-run-flow.ts:462`）：

```ts
      receipt = await this.options.runtime.steer({
        sessionId: session.sessionId,
        source: 'api',
        message: {
          content,
          ...(options.displayContent ? { displayContent: options.displayContent } : {}),
          ...(options.attachments?.length ? { attachments: options.attachments } : {}),
        },
        producerId: 'mcode',
        idempotencyKey: requestId,
        ...(requestedTurnId ? { requestedTurnId } : {}),
      });
```

运行时的 `SteerSessionService` 先看有没有活动回合（`packages/local-runtime-v2/src/service/turn-system/execution/steering/steer-session.service.ts:65`）。有，就把消息推进该回合的引导缓冲并唤醒它（`turn.controller.ts:238`），结果为 `steered`；运行中的回合不接受换模型，带模型覆盖的引导会被拒为 `active-model-override-unsupported`，除非它声明模型只在激活新回合时生效（`steer-session.service.ts:94`）。没有，就把它当普通消息开一个新回合，结果为 `activated`（`steer-session.service.ts:174`）。引导以 `producerId` 加 `idempotencyKey` 去重（`turn.controller.ts:454`）；同一对值还决定回执 ID 与消息键，二者都由 `steer:` 开头（`packages/local-runtime-v2/src/service/turn-system/steering-identity.ts:1`、`8`），要在界面上显示的引导据此推出确定的用户消息 ID（`steer-session.service.ts:409`）。

缓冲里的消息由 Agent 循环在两步之间取走，注入到下一次模型调用之前（`packages/local-runtime-v2/src/service/turn-system/agent-host/execution/user-input-control.ts:46`），被消费时才写成一条展示消息（`user-message-turn-delivery.ts:108`）。回合快要结束时怎么办，取决于 producer 是不是“用户”。用户 producer 只有三个（`packages/local-runtime-v2/src/service/turn-system/agent-host/runner/contracts.ts:129`）：

```ts
/** Producers whose steering is user-visible work rather than machine delivery. */
const USER_STEERING_PRODUCERS: ReadonlySet<string> = new Set([
  'queue-immediate-send',
  'composer-steer',
  'paused-queue-composer',
]);
```

关回合的判断在 `tryBeginClose`（`turn.controller.ts:381`）：

```ts
  if (hasInFlightConsumption(entry)) return { closed: false, reason: 'steer-pending' };
  // Acknowledged non-user steering (cron, communication, task appends) has no
  // requeue lane: hold the close so the runner polls again and consumes it,
  // matching the pre-exit-boundary contract. Losing delivered machine input
  // outweighs finishing the answer one step earlier.
  if (entry.steering.some((message) => !isUserSteeringProducer(message.producerId))) {
    return { closed: false, reason: 'steer-pending' };
  }
  // Exit-boundary rule: user steering that was not consumed by the final step
  // never extends the finished answer. The batch goes to the discard callback
  // (user producers are requeued as plain queries) and the close proceeds.
  if (entry.steering.length > 0) onDiscarded(entry, entry.steering.splice(0), 'exit-close');
  entry.phase = 'closing';
  return { closed: true };
```

用户 producer 的引导在最后一步不会拉长已经完成的回答，而是被重新排成一条普通查询；回合被中止时，它们先尝试直接写进对话，不行再回队列，释放会话前为此最多等 2 秒（`packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts:103`、`113`）。非用户 producer 只有“注入或丢弃”两种命运（`steering-requeue.ts:204`）。

<Callout type="warn">
  TUI 的 `producerId` 是 `mcode`，不在上面三个之列。从代码看，TUI 里运行中按 `Enter` 走的是“非用户引导”语义：模型准备收尾时回合会被拖住，引导仍并入当前回答；但如果在它被消费之前按了 `Esc`，这条引导会被丢弃，不会像 `Alt+Enter` 的消息那样留在队列里。`sendMessage` 另有一个 TUI 没有用到的 `composer-steer` 意图，它以用户 producer 身份引导（`packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts:149`），行为不同。
</Callout>

两者对比：

| | `Enter`：引导 | `Alt+Enter`：排队 |
| --- | --- | --- |
| 运行时入口 | `CliService.steer`，经 `TurnService.steer` | `enqueueMessage`，经 `TurnService.submit` |
| 存在哪里 | 活动回合的内存缓冲（`turn.controller.ts:28`） | `local_runtime_queue_items` 表 |
| 何时生效 | 当前回合下一次取引导，即下一次模型调用之前 | 当前回合结算、释放之后，由派发器认领 |
| 属于哪个回合 | 并入当前回合 | 另起一个新回合 |
| 进程退出或中止 | 未消费的随之丢失 | 保留；中止后队列暂停，确认后继续 |
| 能否换模型 | 不能 | 可以带模型覆盖（`turn-submission.service.ts:316`） |

排队的条目也能“立即发送”：`steerSession` 认领指定条目，以 `queue-immediate-send` 身份引导进当前回合（`packages/local-runtime-v2/src/application/conversation/queue-steer-workflow.ts:31`、`47`）。公开代码里这个入口由 ACP 扩展使用（`packages/tui/src/acp/extensions.ts:173`）。

## 中止、恢复与重试

**中止**。TUI 按 `Esc` 时带的原因是 `user_stop`（`packages/tui/src/tui/controller/run/abort-live-turn.ts:73`）。`TurnService.abort` 若发现目标还只是一个排着队、没开始的回合，就登记取消并唤醒派发器，同时照常去中止活动回合（`initialize.ts:498`）。控制器触发中止信号后最多等 5 秒，结果是 `aborted`、`released`、`abort-timeout`、`not-running` 或 `turn-mismatch` 之一（`turn-system/contracts.ts:89`）；超时在应用层变成 409 的 `local_turn_abort_timeout`（`conversation-application.ts:368`）。用户主动停止还会顺手暂停当前 Goal（`conversation-application.ts:354`）。回合还在产品准备阶段、尚未注册时到来的中止也不会丢：提交在第一次异步检查前就登记了自己，被停下的提交以 `policy:turn-abort:pre-admission` 收场（`turn-execution.service.ts:113`、`153`）。

**重启恢复**。`ready()` 触发一次恢复（`initialize.ts:222`）：回收上个进程遗留的租约与终态、把会话从 `started` 改成 `interrupted`、回收进程启动前认领的队列条目；还有活着的会话或待办时定时重试，默认间隔 10 秒（`initialize.ts:344`），生产环境由运行时归属身份传入具体值（`services.ts:926`）。

**错误重试**有两层。模型调用内部的重试属于 pi 回合执行器，默认最多重试 5 次，基准延迟 1 秒、单次上限 30 秒、重试总耗时上限 120 秒（`packages/agent-core/src/pi-turn-runner/llm-retry.ts:43`）；第 n 次重试先取 1 秒乘以 2 的 n−1 次方（不超过 30 秒），再乘以 0.5 到 1 之间的随机系数，服务端给了 `retry-after` 则以它为准，同样不超过 30 秒（`llm-retry.ts:882`）。回合系统只注入一个观察者（`services.ts:978`），不改策略。回合失败之后，它另用一份白名单给终态错误打上 `retryable` 标记，接线在 `packages/local-runtime-v2/src/service/turn-system/production-composition.ts:505`，名单本身在 `packages/local-runtime-v2/src/service/turn-system/runtime-error-retry-policy.ts:3`：

```ts
const RETRYABLE_RUNTIME_ERROR_CODES = new Set<number>([
  LLM_ERROR_CODES.LLM_RATE_LIMITED,
  LLM_ERROR_CODES.LLM_UPSTREAM_ERROR,
  LLM_ERROR_CODES.LLM_TPM_RATE_LIMITED,
  LLM_ERROR_CODES.LLM_CLUSTER_OVERLOADED,
]);
```

四个码对应 50111（HTTP 429）、50113（其余上游 4xx、5xx）、50150（TPM/RPM 短时限流）、50151（HTTP 529 集群过载）（`packages/shared/src/llm-error-classifier.ts:655`）。标记挂在 `LocalRuntimeTerminalError` 上（`packages/local-runtime-v2/src/service/turn-system/agent-host/events/turn-commit-pipeline.ts:58`），随回合结局交给上层，不过本仓库的非测试代码里没有找到读取这个标记的地方。TUI 判断 `/retry` 是否可用时，是按错误码和错误文本自己再判一遍：认证失败、403、额度用尽、内容安全拦截等几类不给重试，其余默认可以（`packages/tui/src/tui/controller/runtime/runtime-error-presentation.ts:48`）。

下一篇：[Agent 服务：内置 Agent 与提示词](https://daiw.net/manual/minimax-code/agent-service)——回合里用的是哪个 Agent，它的提示词从哪里来。
