Files
Cyrene_v2/internal/services/gateway/session_orchestrator.go
T
AskaEth ba1974038a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 并行提示接线(第二路 + 串行续跑路;OtherMessageID/Queued 取自另一在途路,无其它在途路则不挂)
2026-10-05 12:21:24 +08:00

458 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"io"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件实现会话消息编排器(DevDocs/design/15)。
//
// 它刻意不是任务引擎。design/04 §2(task/subagent)决定
// 一个重活是否被推到后台;本编排器决定
// 会话忙碌时新的用户消息如何排队与续跑。两层
// 正交且可组合:一个会话跑一个任务(04),新消息在
// 运行中途到达(15),编排器把它握在有界待处理队列中,
// 并在当前回合结束后带着上一个回合的上下文续跑它。
//
// 非阻塞是硬约束:Submit/Interrupt 绝不等待运行中的
// 回合。队列按会话有界,且跨会话隔离,这里不做
// 任何身份判定 —— 编排器只排序意图/消息,
// 身份始终留在 channel 的 actor 元数据中(项目红线)。
// Mode 是编排器的每会话忙碌状态通道。它被暴露出来,
// 使 loop/subagent 钩子能把它升到 executing/subagent(一个
// 组合根之后可能接线的注入点),但编排器自身只用
// mode != ModeIdle 跟踪抽象的「忙碌」。
type Mode string
const (
// ModeIdle 意味着该会话没有在飞回合、也没有待处理续跑。
ModeIdle Mode = "idle"
// ModeThinking 意味着有回合在飞(起始的初始状态)。
ModeThinking Mode = "thinking"
// ModeExecuting 意味着回合正在执行工具/子步骤。
ModeExecuting Mode = "executing"
// ModeSubagent 意味着回合被委派给子代理(design/04 §2)。
ModeSubagent Mode = "subagent"
)
// 经 WS 通道推给前端的状态事件类型见 session_events.go(它们从本文件拆出,
// 使编排器本体保持在「单文件 ≤500 行」的硬约束内)。
const (
// defaultMaxPending 限定每会话的待处理队列(design/15 §4)。
defaultMaxPending = 5
// defaultMaxTail 限定为续跑而保留的对话尾部,使
// 续跑的上下文是增量的,而不是全量重放(design/15 §2.2、§4)。
defaultMaxTail = 20
// 调用方传入空会话 id 时使用 defaultSessionID。
defaultSessionID = "web"
)
// SubmitResult 是把一个用户回合交给编排器的结果。它
// 总是不阻塞地返回:Accepted 为 false 仅在回合被
// 拒绝时(待处理队列满,或空回合),绝不因为回合忙碌。
type SubmitResult struct {
// Accepted 报告回合是否被接收。为 false 且带
// PendingFull 或 Empty 时携带拒绝原因;它绝不意味着「稍后重试」。
Accepted bool
// Immediate 报告因会话空闲而立即开始了一个回合。
Immediate bool
// Pending 报告该回合排在忙碌回合之后入队了。
Pending bool
// QueueDepth 是本次插入后的待处理深度(Immediate 时为 0)。
QueueDepth int
// PendingFull 报告回合被拒绝,因为队列已满
// (design/15 §4:有界待处理,超容量时拒绝并给出提示)。
PendingFull bool
// Empty 报告回合因既无文本也无图片而被拒绝。
Empty bool
// Duplicate 报告该回合被判为**重复到达**(G4 幂等):同一个 clientMsgId
// 此前已经在本会话落过库,因此本次既不落库、也不启动回合。它是「接住了、
// 但什么都没做」的诚实答案,绝不是拒绝 —— 客户端据此把本地乐观气泡对齐到
// MessageID,而不必重试、也不会看到第二个回合。
Duplicate bool
// MessageID 是本次提交对应的那条用户消息 id:正常路径是刚落库的消息
// (带幂等键时是编排器预分配的那一个),重复路径是既有那条。省略
// clientMsgId 的老客户端为 ""。
MessageID string
// Parallel 报告本次提交被**升为一条独立的并行路**(design/15 §2.5:显式声明
// parallel 且并发额度未满)。它总与 Immediate 同时为真 —— 第二路是「立即起」,
// 不排到队首;额度已满或未声明时它恒为假,本次落回既有排队语义。
Parallel bool
// TurnID 是本次**新起的那一路**的权威回合标识(design/15 §2.5.9):编排器生成、
// loop 复用,并与该路全部流帧的 turnId 同值。入队(Pending)或被拒时为空串,
// 因为那时还没有路。
TurnID string
// Err 在同步路径上于运行出错时设置;REST 调用方
// 用它返回 5xx,而不是编造回复。
Err error
}
// TurnRunner 执行一个 agent 回合并返回其结果。网关把它
// 接到 loop 加上流式广播上,因此编排器对回合
// 实际如何驱动保持不可知(它绝不重新实现 loop 引擎)。
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
// StateSink 与它的带 turnId 变体(TurnStateSink)住在 session_state.go,
// 与「谁在读状态 / 状态帧怎么出去」放在一起。
//
// OrchestratorOptions 配置 Orchestrator。Run 与 Log 必需
// (Log 可以是丢弃型 logger);Emit 可选(为 nil 时状态事件被丢弃)。
// MaxPending/MaxTail 非正时回退到默认值。
type OrchestratorOptions struct {
Run TurnRunner
Emit StateSink
EmitTurn TurnStateSink
Log *slog.Logger
MaxPending int
MaxTail int
// MaxParallelTurns 是单会话在途路数的上限(design/15 §2.5.7):
// 非正 ⇒ 默认 2;1 ⇒ 关闭真并行(恒 1 路,完全回落到 steer-only 现状);
// 超过硬上限 3 ⇒ 钳到 3。它由 resolveMaxParallelTurns 解析。
MaxParallelTurns int
// History 是可选的持久化聊天历史存储(design/16 §3.3)。
// 为 nil 时每次 Append 都被跳过,因此编排器无需存储也能运行
// (测试 / 未接 history 存储构建的网关)。
History HistoryStore
// OnPersisted 非 nil 时,会在用户或助手消息写入后立即
// 带着完整已存消息被调用,使传输层能广播
// 一个 `message_persisted` 事件用于跨端实时同步(design/16 §4.2)。
// 为 nil 时丢弃(测试 / 未接传输钩子构建的网关)。
OnPersisted func(history.Message)
}
// sessionState 是每会话的消息状态(design/15 §2)。每次变更
// 都在 Orchestrator 互斥锁下发生;实际的 loop 运行在锁外
// 完成,因此慢的模型调用绝不阻塞 Submit。
type sessionState struct {
mode Mode
// gen 是会话内**世代号的分配源**:每条新路取一个(单调、不复用)。
// 旧口径里它同时兼任「当前回合」的身份与陈旧判据;并行落地后这两件事
// 都归 st.legs —— 「这一路还算数吗」=「它还在 st.legs 里吗」,
// 因此启动第二路绝不会让第一路变成陈旧(design/15 §2.5.9)。
gen uint64
// legs 是该会话**当前在途**的路(design/15 §2.5 的「路」),键为路的世代号。
// 单路时它至多一条,且其行为与引入多路之前逐字相同;len(legs) 就是
// 忙态帧里的 activeTurns,也是「所有路都收尾了吗」的判据。
legs map[uint64]*turnLeg
// legSeq 是路序号(1 基)的分配源,即 `turn_begin.data.leg`。
legSeq int
pending []pendingMsg
tail []model.Message // 用于上下文续跑的有界对话尾部
// fence 是当前在途**串行**路的启动围栏(见 session_turn_fence.go):下一个
// 串行回合以它为先驱,等本回合真正返回后才开始 —— 因此中断后立即到达的
// 消息绝不会在被取消回合落定其 checkpoint 之前读到那份旧快照。并行路不接入
// 这条链(它的归属键与谁都不同,无需等待)。
fence turnFence
// clientMsgs 记录本会话已认领的 clientMsgId → 该消息的 id(G4 幂等)。
// 它只在状态锁下读写,且只是**快路径 + 同进程并发仲裁**:跨重启的判重由
// chat_messages 上 (session_id, client_msg_id) 的部分唯一索引裁决
// (单二进制单进程,不存在跨进程并发)。见 session_persist.go。
clientMsgs map[string]string
// clientMsgOrder 是 clientMsgs 的插入顺序,用于有界淘汰
// (maxClientMsgsPerSession):淘汰只丢快路径,存储仍是权威。
clientMsgOrder []string
// pendingSeq 是待处理队列的单调递增序号源(design/15 §2.4 的转向认领键)。
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
// 不稳定;按 seq 认领已注入的消息,才不会被同一时刻的追加或清空误伤。
pendingSeq uint64
}
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
// 在该消息到达时所用的同一记忆隔离边界 —— 且自 design/22 起
// 同一角色(env.PersonaID)—— 之下运行,并携带产出该回合的
// 客户端 deviceID(design/16 §4.5),使续跑的助手回复被标上
// 发送该消息的同一个客户端;以及该回合的行为模式(design/16
// §5.1),使续跑回复与发起它的用户消息以同一模式
// 被持久化。
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
deviceID string
mode string
at time.Time
// seq 是这条待处理回合在**本会话**队列里的单调递增序号(design/15 §2.4)。
// 它只在状态锁下分配,是转向注入「注入=已消费」认领的依据:队列会被追加、
// 被弹出、被中断清空,序号却能唯一定位这一条。
seq uint64
// persisted 是这条待处理回合已落库的用户消息,供它的续跑回合结算时回写
// 文档抽取结论(design/24 §6.1):它在入队那一刻就落了库,而那时抽取还没
// 发生。见 session_document_status.go。
persisted []history.Message
// voice 是这条待处理回合的语音收尾回调(协同 C1):它源自 voice_input 时
// 非 nil,使续跑回合结算后仍能把音频定向回发起它的那根连接。
voice VoiceReplyFunc
}
// Orchestrator 拥有每会话的消息状态机。它对并发安全
// 且非阻塞:Submit/Interrupt 只在变更状态时加锁,然后把
// 实际运行交给一个 goroutine。它是 chat 路径上「该会话是否
// 忙碌」的唯一归属者;loop/task/subagent 引擎被复用,而不是重建。
type Orchestrator struct {
mu sync.Mutex
states map[string]*sessionState
run TurnRunner
emit StateSink
emitTurn TurnStateSink
log *slog.Logger
history HistoryStore
onPersisted func(history.Message)
maxPending int
maxTail int
// maxParallelTurns 是单会话在途路数的生效上限(已解析,见
// resolveMaxParallelTurns):1 = 关闭真并行(恒 1 路)。
maxParallelTurns int
}
// NewOrchestrator 从 opts 构建编排器。Run 必需:它是编排器
// 复用的回合驱动器(loop,绝不是重新实现的引擎)。
// Run 为 nil 会让每次 Submit 拒绝而非运行,因此错误配置
// 不可能静默地让会话处于忙碌。测试在此注入假 runner。
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
maxPending := opts.MaxPending
if maxPending <= 0 {
maxPending = defaultMaxPending
}
maxTail := opts.MaxTail
if maxTail <= 0 {
maxTail = defaultMaxTail
}
if opts.Log == nil {
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Orchestrator{
states: make(map[string]*sessionState),
run: opts.Run,
emit: opts.Emit,
emitTurn: opts.EmitTurn,
log: opts.Log,
history: opts.History,
onPersisted: opts.OnPersisted,
maxPending: maxPending,
maxTail: maxTail,
maxParallelTurns: resolveMaxParallelTurns(opts.MaxParallelTurns),
}
}
// Submit 把一个用户回合交给会话。它绝不阻塞:空闲会话在
// 后台 goroutine 上启动回合并返回 Immediate;忙碌会话把
// 回合入队到有界待处理队列并返回 Pending;队列
// 满时以 PendingFull 拒绝。此处不自动检测打断词 ——
// 调用方(前端/意图分类器)在用户要求停止时调用 Interrupt,
// 依 design/15 §3(打断是一种意图,不是身份判定)。
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
return sr
}
// SubmitSync 是 REST 非流式路径所用的同步变体。空闲会话
// 把回合跑完并返回 Result;忙碌会话不阻塞地返回
// Pending 结果,使调用方能以非阻塞 ack 回复
// (design/15 §2.1)。它绝不等待在飞回合。
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
}
// submitInternal 是 Submit/SubmitSync 共用的主体。sync 选择
// 同步回合路径(REST)还是后台 goroutine 路径(WS)。
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
if o.run == nil {
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
return SubmitResult{Accepted: false}, nil
}
if o.messagesEmpty(messages) {
return SubmitResult{Empty: true}, nil
}
o.mu.Lock()
st := o.states[sessionID]
if st == nil {
st = &sessionState{mode: ModeIdle}
o.states[sessionID] = st
}
deviceID := deviceIDFrom(ctx)
msgMode := modeFrom(ctx)
// 两道门(队列满 / clientMsgId 重复)由 admitLocked 在锁内判定;它不广播,
// 状态事件在这里解锁之后再发。
a := o.admitLocked(ctx, st, sessionID)
if a.full {
rejected := o.rejectedIdentityLocked(ctx, st, sessionID)
o.mu.Unlock()
o.emitPendingFull(ctx, sessionID, a.mode, rejected)
return SubmitResult{PendingFull: true}, nil
}
if a.dup {
o.mu.Unlock()
o.emitState(sessionID, EventChatDuplicate, map[string]any{"sessionId": sessionID, "clientMsgId": a.hint.ClientMsgID, "messageId": a.hint.MessageID})
return SubmitResult{Accepted: true, Duplicate: true, MessageID: a.hint.MessageID}, nil
}
// 认领成功:把预分配的消息 id 盖进 ctx。空 clientMsgId 时它是零值,ctx 与
// 加入该字段之前逐字相同。
ctx = withClientMsgHint(ctx, a.hint)
if st.mode != ModeIdle {
// 忙时:**显式**声明并行且并发额度未满 ⇒ 升为第二路(design/15 §2.5.1
// 的 I1′)。额度判定与登记在同一个临界区(startParallelLocked 在锁内完成
// 登记),因此并发到达的两条并行消息不可能双双升级、突破上限。
// 未声明 / 额度已满 / 队列已满一律落回既有排队语义:不报错、不打断。
if parallelRequested(ctx) && o.canStartParallelLocked(st) {
return o.startParallelLocked(ctx, st, sessionID, messages, env, deviceID, msgMode, a.hint), nil
}
return o.enqueueLocked(ctx, st, sessionID, messages, env, deviceID, msgMode, a.hint), nil
}
// 本回合的围栏以当前在途串行回合的收尾信号为先驱,使中断后来到的这条
// 消息不会抢在被取消回合落定旧快照之前开跑(session_turn_fence.go)。
turn := o.seedTurnLocked(ctx, st, sessionID, messages)
leg, runCtx, cancel := o.beginLegLocked(ctx, st, newTurnID(), false, newTurnFence(st.fence.done))
st.fence = leg.fence
// 本次启动后的在途路数:`turn_begin.activeTurns` 与忙态帧**同源**(正在收尾的路
// 也计入),因此它只能在锁内读一次 —— 解锁之后再读数会随另一路的收尾漂移。
activeTurns := len(st.legs)
o.mu.Unlock()
// 本回合的转向源(design/15 §2.4):loop 在**回合内边界**回调它重读转录,把忙时
// 新到、已入 pending 的用户消息并入**同一回合**的下一次模型调用(I1:绝不新开
// 第二回合)。它挂在 runCtx 上,因此只作用于本回合;回合结算或被打断后随 ctx 消亡。
// 它同时带上本路的 turnId,使 `steer_ack` 的帧壳能指明「被哪一路吃进」。
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen, leg.turnID))
o.emitState(sessionID, EventSessionBusy, busyPayload(sessionID, ModeThinking, activeTurns))
// 落库 → 按路配对 → 下发 `turn_begin`:与并行路的启动共用同一段,两条启动
// 路径的帧序因此同形(见 publishLegStart)。
o.publishLegStart(ctx, sessionID, leg, messages, deviceID, msgMode, env.PersonaID, activeTurns)
if sync {
return o.runImmediateSync(runCtx, sessionID, leg, turn, env, deviceID, msgMode, a.hint.MessageID)
}
go o.goTurn(sessionID, leg, turn, env, runCtx, cancel, deviceID, msgMode)
return SubmitResult{Accepted: true, Immediate: true, MessageID: a.hint.MessageID, TurnID: leg.turnID}, nil
}
// runImmediateSync 在**调用方** goroutine 上跑完刚启动的那个回合(REST 同步
// 路径),结算它并把回合结果一并交回调用方。它与 goTurn 是同一轮的两个驱动
// 方式(前台等待 / 后台续跑),共享 runTurnFenced + completeTurn 这一对,因此
// 谁驱动都产生同样的持久化与状态迁移。
func (o *Orchestrator) runImmediateSync(ctx context.Context, sessionID string, leg *turnLeg, turn []model.Message, env loop.SessionEnv, deviceID, mode, msgID string) (SubmitResult, *loop.Result) {
res, err := o.runTurnFenced(ctx, sessionID, turn, env, leg.fence)
leg.fence.release()
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, leg, res, err, deviceID, mode, env.PersonaID)
return SubmitResult{Accepted: true, Immediate: true, MessageID: msgID, TurnID: leg.turnID, Err: err}, res
}
// goTurn 在后台 goroutine 上运行单条**路**,并在它结束时
// 把结果交给 completeTurn,使编排器能续跑队列。
// deviceID 用发送该回合的客户端标注持久化助手回复
// (design/16 §4.5),mode 用该回合的行为模式(design/16 §5.1),而 env 是
// 该回合解析出的角色/隔离环境(design/22 §4.5),因此回复被
// 归到产出它的角色名下。leg.fence 是本路的启动围栏:它先等上一**串行**回合
// 收尾,并在本路返回时放行下一路(session_turn_fence.go)。
func (o *Orchestrator) goTurn(sessionID string, leg *turnLeg, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
defer cancel()
defer leg.fence.release()
res, err := o.runTurnFenced(ctx, sessionID, messages, env, leg.fence)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, leg, res, err, deviceID, mode, env.PersonaID)
}
// completeTurn 在一条路结算后运行(它完成、出错或被
// 取消)。它把助手回复折进会话的续跑尾部,
// 然后弹出下一条待处理回合并带着该尾部续跑它 —— 因此下一条
// 消息「记得」先前上下文,而不是从空白开始
// (design/15 §2.2:增量,而不是全量重放)。**已经失效的路**(被
// 打断、被取代:不在 st.legs 里了)是空操作。
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
//
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本路用户消息的
// document 列里此前只有引用,装配期解析出的 status/chars/truncated 在这里补上
// —— 见 session_document_status.go;语音回合的音频回复同样在此收尾(协同 C1,
// 见 session_voice.go):只有这里知道这个回合真正产出了什么。
//
// 多路口径(design/15 §2.5.3):本路先把自己从在途路集合摘掉,**再**决定续跑
// 还是报空闲。续跑仍由结算中的这一路负责(弹队首、起一条新的**串行**路,占一个
// 额度);只有在途路全空且队列也空时才报空闲 —— 单路时该条件恒等价于现状。
func (o *Orchestrator) completeTurn(sessionID string, leg *turnLeg, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
gen := leg.gen
res = o.persistTurnReply(sessionID, gen, res, runErr, deviceID, mode, personaID, leg.turnID)
o.persistDocumentStatus(sessionID, gen, res)
o.dispatchVoiceReply(sessionID, gen, res)
o.mu.Lock()
st := o.states[sessionID]
if o.legLocked(st, gen) == nil {
o.mu.Unlock()
return nil
}
delete(st.legs, gen)
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
}
if len(st.pending) > 0 {
next := st.pending[0]
st.pending = st.pending[1:]
st.tail = o.boundedTail(concatMessages(st.tail, next.messages...))
// 续跑回合同样带自己的转向源(design/15 §2.4):严格 FIFO 弹出下一条之后,
// 期间**仍在 pending** 的后到消息会在本回合的下一个边界被注入,而不是各起一回合。
// 本路的 Run 已经返回(我们正处在它的结算里),因此被续跑的回合无需再等
// 任何在途路:围栏只负责放行它后面的那一个。
runLeg, runCtx, cancel := o.beginLegLocked(context.Background(), st, newTurnID(), false, newTurnFence(nil))
st.fence = runLeg.fence
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, runLeg.gen, runLeg.turnID))
// 续跑路同样可能有**别的在途路**(并行的另一路还在飞):它也照答自己的那条消息,
// 同样需要「另有一路正在进行」的结构化摘要(design/15 §2.5.6,主人 2026-10-05
// 裁决:同一类串话、同一个修法)。快照与上面同一个临界区取(st.legs / st.pending),
// 没有其它在途路时不挂 ⇒ 单路续跑零成本、行为逐字不变。
if notice, ok := parallelNoticeLocked(st, runLeg.gen); ok {
runCtx = loop.WithParallelNotice(runCtx, notice)
}
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
// 记到**这一路**上(design/24 §6.1);语音回调同理(协同 C1:忙时排队
// 不许把音频丢掉)。
runLeg.docs = turnDocuments{gen: runLeg.gen, msgs: next.persisted}
runLeg.voice = voiceTurn{gen: runLeg.gen, sinks: voiceSinksOf(next.voice)}
depth := len(st.pending)
activeTurns := len(st.legs)
resume := append([]model.Message(nil), st.tail...)
env := next.env
o.mu.Unlock()
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth, "activeTurns": activeTurns})
// 续跑也是一条**新路**,因此同样发一次 turn_begin(kind:"turn"):它的
// 归属消息就是刚从队首弹出的那条(它在入队时就已落库,id 取自
// next.persisted)——没有这一帧,客户端就不知道为什么气泡突然开始回答了。
o.emitTurnBegin(sessionID, runLeg, userMessageIDs(next.persisted), activeTurns)
// 续跑路刚起,就把刚从队首弹出的那几条消息认领到它名下(design/15 §2.5.9
// 的两段式第二段):它们入队那一刻就落了库,而那时还没有路,此刻才谈得上
// 归属 —— 补写之后它们与该路的助手回复同值。必须在锁外(它写库)。
o.assignTurnID(sessionID, runLeg.turnID, next.persisted)
go o.goTurn(sessionID, runLeg, resume, env, runCtx, cancel, next.deviceID, next.mode)
return nil
}
// 还有别的路在飞 ⇒ **不报空闲**(design/15 §2.5.3 的时点收紧,仅在并行时
// 与现状不同:单路时此刻 len(st.legs) 必为 0,因此这一分支不可达)。
if len(st.legs) > 0 {
o.mu.Unlock()
return nil
}
st.mode = ModeIdle
o.mu.Unlock()
o.emitState(sessionID, EventSessionIdle, busyPayload(sessionID, ModeIdle, 0))
return nil
}