回合系统:受理、排队与执行
一次提交如何经过受理、入队、派发、执行与收尾;运行中 Enter 的“引导”与 Alt+Enter 的“排队”在代码里有何不同;租约、中止与错误重试的数字。
会话系统管“存”,回合系统管“跑”:一条消息能不能跑、什么时候跑、谁持有这个会话、跑完怎样收尾。它住在 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 循环的一层 |
怎么用
| 操作 | 按键或命令 | 走到运行时的调用 |
|---|---|---|
| 空闲时发送 | 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).
一次提交的一生
提交。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.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:
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,它的提示词从哪里来。