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

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

作者 David更新于 第 11 篇(共 33 篇)

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

/** 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>;
}

目录分工如下。压缩与失控保护也在这里,但分别留给上下文管理与压缩和Agent 模块。

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

怎么用

操作按键或命令走到运行时的调用
空闲时发送EntersendMessage,最终 submit 且 allowQueue: false
运行中调整当前响应Enter(run.submit-guidance)steer
运行中排到下一轮Alt+Enter(run.queue-draft)enqueueMessage,最终 submit 且 allowQueue: true
取回最近一条排队消息Alt+Up 或 Shift+Left删除该队列条目,内容放回输入框
中断当前回合EscabortSession,原因 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).

一次提交的一生

图表加载中…

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

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):

  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 循环与回合执行器。

回合结束后由 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):

      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):

/** 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):

  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)。

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),行为不同。

两者对比:

Enter:引导Alt+Enter:排队
运行时入口CliService.steer,经 TurnService.steerenqueueMessage,经 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:

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 与提示词——回合里用的是哪个 Agent,它的提示词从哪里来。

本页目录