package gateway import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "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" ) // 本文件归属 design/15 §2.5「忙时并行回合(多路)」的**基础管线**(主人 // 2026-10-05 裁决 B)。它从 session_orchestrator.go 拆出,使编排器本体继续只留 // 状态机,并保持在「单文件 ≤500 行 / 函数 ≤80 行」的硬约束内。 // // 它做五件事: // - 解析上行的并行声明(ctx 通道,与 deviceID / mode / 幂等键同一种机制); // - 判定并发额度(默认 2,硬上限 3,<2 = 关闭真并行 ⇒ 完全回落到 steer-only 现状); // - 在状态锁下登记一条新路(自己的世代号 / turnId / 取消链 / checkpoint 归属键); // - 忙态帧的在途路数(activeTurns)与排队原因(reason); // - 下发新帧 `turn_begin`(`publishLegStart` / `emitTurnBegin`):单路 // kind:"turn"、并行路 kind:"parallel",两条路径都发。`steer_ack` 的帧壳 // turnId 由 session_steer.go 的转向游标带出,与这里**取值同源**(leg.turnID)。 // // 它**不做**(留给后续切片):第二路的进行态 system 摘要。写类工具的文件级互斥 // 在**两个地方**落地:互斥**身份**由 beginLegLocked 对每一条在途路盖 // (loop.WithToolLegID,取值 = 本路 turnID),登记 / 判忙 / 释放由工具侧 // (internal/runtime/tool/fileclaims.go)在执行前后来做。 // 并发额度的取值口径(design/15 §2.5.7)。 const ( // defaultMaxParallelTurns 是单会话在途路数的默认上限。 defaultMaxParallelTurns = 2 // maxParallelTurnsHardCap 是硬上限:配置再多也钳到它。上限存在的意义是 // 防失控(对齐 OpenAI Swarm / CrewAI 的「有上限的并行 worker」口径), // 而不是成本旋钮 —— 成本按 design/15 §2.5.7 由既有 429/降级链兜。 maxParallelTurnsHardCap = 3 // pendingAckReasonParallelLimit 是 pending_ack 的 reason 取值(design/15 §2.6): // 「声明了并行,但并发额度已满,本条已落回排队」。它不含任何内容,因此该帧 // 仍在「状态机信号」白名单内。 pendingAckReasonParallelLimit = "parallel_limit" // parallelLogSource 是并发路相关日志的子功能来源标签。 parallelLogSource = "session-parallel" // turn_begin 的 kind 取值(忙时并行规范草案 §2.4):服务端**实际**如何起这 // 一路 —— 新回合 / 续跑 = "turn",显式声明并行且照准 = "parallel"。它**仅供 // 展示**,客户端的状态机一律按「messageIds 是否含本气泡的 messageId」判定。 turnBeginKindTurn = "turn" turnBeginKindParallel = "parallel" ) // turnLeg 是会话内一条在途回合(design/15 §2.5 的「路」)。 // // 单路时它恒只有一条,且其全部行为与引入本结构之前逐字相同;真并行时同一会话 // 有多条,每条各有自己的取消链(I3′)、自己的围栏、自己的历史配对(turnDocs / // voiceTurn)与自己的 checkpoint 归属键。 // // 它只在 Orchestrator.mu 下被读写,除了 cancel(取消是并发安全的)与该路自己的 // goroutine 读取 turnID/gen(出生后不可变)。 type turnLeg struct { // turnID 是本路的权威回合标识(design/15 §2.5.9):编排器生成,挂进 runCtx // 的 loop.WithTurnID,并由 loop 盖在该路全部流帧上。`turn_begin` 帧与 // `steer_ack` 的帧壳 turnId 都直接取它 —— 两处必须同值,否则客户端按 // turnId 两级分桶会立刻失配。 turnID string // gen 是本路的世代号:会话内单调、永不复用。它取代了旧的「会话当前代次」 // 概念 —— 陈旧判据从「st.gen == gen」变成「本路还在 st.legs 里」,因此启动 // 第二路绝不会让第一路变成陈旧。 gen uint64 // leg 是本路的会话内序号(1 基),即 `turn_begin.data.leg`(「并行路 2」的 // 展示口径)。除了发帧,日志也用它。 leg int // parallel 报告本路是否为**显式声明的并行路**:它决定 checkpoint 归属键 // (单路逐字等于 sessionID,并行路为 {sessionID}#{turnID})。它**不**决定 // 工具互斥身份 —— 那条身份每一路都有(loop.WithToolLegID),否则普通回合 // 与并行路同文件写会并发。 parallel bool // cancel 派生自提交时的 ctx,是**本路独占**的取消链:任一路的注入 / 取消 // 都不影响另一路已发起的工具(I3′)。 cancel context.CancelFunc // fence 是本路的启动围栏(session_turn_fence.go)。串行路接入会话级围栏链, // 并行路恒自带一道无须等待的围栏 —— 它的归属键与任何在途路都不同, // 因此不可能读到对方尚未落定的快照。 fence turnFence // docs 是本路已落库的用户消息(design/24 §6.1 的文档结论回写), // voice 是本路的语音收尾回调(协同 C1)。二者都按**路**配对:并行时两路 // 各自回写自己那几行,陈旧的路读不到别人的条目。 docs turnDocuments voice voiceTurn } // parallelTurnKey 是携带「本次提交显式声明并行」的 context 键。它从 WS 帧 / // REST body 边界引入编排器,与 deviceID / mode / 幂等键走同一种机制 —— // Submit 的签名因此保持不变,所有既有调用方零改动。 type parallelTurnKey struct{} // withParallelTurn 返回携带并行声明的 context。false 是空操作:省略 // `parallel` 的请求在 ctx 上不留任何痕迹,行为与加入该字段之前逐字相同。 func withParallelTurn(ctx context.Context, parallel bool) context.Context { if !parallel { return ctx } return context.WithValue(ctx, parallelTurnKey{}, true) } // parallelRequested 报告该提交是否显式声明了并行。服务端**不做启发式自动升级** // (design/15 §2.5.1):触发权在客户端,否则「同一条消息被如何作答」会随负载 // 漂移,「恰好一次」的直觉被破坏。 func parallelRequested(ctx context.Context) bool { if ctx == nil { return false } v, _ := ctx.Value(parallelTurnKey{}).(bool) return v } // resolveMaxParallelTurns 把配置值解析为生效的并发上限:非正 ⇒ 默认 2; // 1 ⇒ 关闭真并行(恒 1 路,完全回落到 steer-only 现状);超过硬上限 ⇒ 钳到 3。 // // 它是纯函数,因此「默认 / 关闭 / 钳位」三档边界可以被直接钉住(见 // session_parallel_test.go),而不必去构造一个完整的网关。 func resolveMaxParallelTurns(configured int) int { if configured <= 0 { return defaultMaxParallelTurns } if configured > maxParallelTurnsHardCap { return maxParallelTurnsHardCap } return configured } // canStartParallelLocked 报告此刻能否把一条声明了并行的忙时消息升为第二路 // (design/15 §2.5.1 的 I1′)。三个条件缺一不可: // // - 真并行没有被关掉(上限 ≥ 2); // - 会话已有**至少一条在途路**(没有在飞的路,就没有「并行」可言:那种情况 // 下 mode 非空闲只可能来自外部 SetMode 钩子,语义上应照旧排队); // - 在途路数**未用满**额度。 // // 必须在 o.mu 下调用。 func (o *Orchestrator) canStartParallelLocked(st *sessionState) bool { if st == nil || o.maxParallelTurns < defaultMaxParallelTurns { return false } return len(st.legs) >= 1 && len(st.legs) < o.maxParallelTurns } // startParallelLocked 在状态锁下把一条忙时到达、且显式声明并行的用户消息升为 // **独立的一路**(design/15 §2.5.1 / §2.5.3),并在锁外起 goroutine、返回本次提交 // 的判定。它必须在 o.mu 下调用,且由调用方保证 canStartParallelLocked 已经成立 // (额度判定与登记必须在同一个临界区,否则两条同时到达的并行消息会双双升级、 // 突破上限)。 // // 三件事一次做完: // - 本路的窗口取**该会话转录快照 + 本条消息**(I4:每路自己的窗口,绝不在运行中 // 看见对方的半成品)。它刻意**不**改写 st.tail —— 尾部是会话级增量游标、属于 // 串行续跑链,并行路不该把自己的窗口塞进别人要续跑的上下文; // - 本路的 checkpoint 归属键升为 {sessionID}#{turnID}(§2.5.9 —— LangGraph // `Send` 扇出的同族要求:并行分支必须有独立归属),因此它**不需要等任何在途路 // 收尾**:归属键不同就不可能读到对方的快照; // - 本路的用户消息立即落库(与单路同一条两段式),使转录与跨端同步立刻看得到它。 func (o *Orchestrator) startParallelLocked(ctx context.Context, st *sessionState, sessionID string, messages []model.Message, env loop.SessionEnv, deviceID, mode string, hint clientMsgHint) SubmitResult { turn := o.startTurnMessages(ctx, sessionID, messages) // 第二路的 ctx **脱离提交请求的作用域**:它是一条后台路,提交方拿到的只是 // 「已受理」的 ack(WS 帧早已返回、REST 回 202),因此请求 ctx 随后被取消 // 绝不该把它连坐杀掉 —— 否则 REST 同步入口的并行路会在写响应的那一刻就死。 // WithoutCancel 保留 ctx 上的值(deviceID / 幂等键 / trace),只丢取消信号; // 该路仍可被编排器自己的取消链(Interrupt / InterruptAll)取消。 // 这与续跑路用 Background 派生属于同一条口径:后台路的生死由编排器说了算。 leg, runCtx, cancel := o.beginLegLocked(context.WithoutCancel(ctx), st, newTurnID(), true, newTurnFence(nil)) activeTurns := len(st.legs) o.mu.Unlock() runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, leg.gen, leg.turnID)) o.emitState(sessionID, EventSessionBusy, busyPayload(sessionID, ModeThinking, activeTurns)) logger.Scoped(o.log, source, parallelLogSource).Info("parallel leg started", "session", sessionID, "turn", leg.turnID, "leg", leg.leg, "active_turns", activeTurns) // 落库 → 按路配对 → `turn_begin{kind:"parallel"}`:messageIds 必须是刚落库的 // 权威 id,因此这三步的顺序不能颠倒(与单路启动共用同一段)。 o.publishLegStart(ctx, sessionID, leg, messages, deviceID, mode, env.PersonaID, activeTurns) go o.goTurn(sessionID, leg, turn, env, runCtx, cancel, deviceID, mode) return SubmitResult{Accepted: true, Immediate: true, Parallel: true, MessageID: hint.MessageID, TurnID: leg.turnID} } // beginLegLocked 在状态锁下登记一条新路(design/15 §2.5.9)。它分配该路自己的 // 世代号、派生**独立的**取消链(I3′),把 turnID 与(并行时的)**checkpoint 归属 // 键**、以及(每一路的)**工具互斥身份**盖进 runCtx,并把 runCtx 交回调用方 —— // 调用方解锁后再起 goroutine。 // // turnID 由**编排器**生成并由 loop 复用(design/15 §2.5.9 的 ⚠️ 必改项): // `turn_begin` 帧由编排器在调用 Run 之前发出,若 Run 内部再自己生成一个, // 客户端按 turnId 两级分桶会立即失配。单路时该 id 仍是每回合全新的随机值, // 因此对客户端**无可见差异**。 // // 两条 ctx 通道在这里分道(见 loop/session_ctx.go):checkpoint 归属键 // (loop.WithParallelLeg)**只给并行路**,工具互斥身份(loop.WithToolLegID) // **每一路都盖**。它们刻意不共用 —— 共用会把普通回合的 checkpoint 键从 // sessionID 升成 `{sessionID}#{turnID}`,连带改变崩溃恢复语义。 // // 它同时把会话标成忙(ModeThinking),使 mode 在任一路在途时恒非空闲。 func (o *Orchestrator) beginLegLocked(ctx context.Context, st *sessionState, turnID string, parallel bool, fence turnFence) (*turnLeg, context.Context, context.CancelFunc) { st.gen++ st.legSeq++ runCtx, cancel := context.WithCancel(ctx) runCtx = loop.WithTurnID(runCtx, turnID) // 工具互斥身份:**每一条在途路都盖**(普通回合 / 并行路 / 队首续跑路)。 // 漏掉普通回合的代价是真实的:「1 普通回合 + 1 并行路」时普通回合既不登记 // 也不被检查,它与并行路同文件写仍会并发(design/15 §2.5.7 的文件级互斥 // 因此形同虚设)。取值就是本路的 turnID:路内唯一且稳定,于是同一条路自己 // 的多次工具调用不会互相判忙,而两条不同的路一定取到不同的值。 runCtx = loop.WithToolLegID(runCtx, turnID) leg := &turnLeg{turnID: turnID, gen: st.gen, leg: st.legSeq, parallel: parallel, cancel: cancel, fence: fence} if parallel { runCtx = loop.WithParallelLeg(runCtx, turnID) } if st.legs == nil { st.legs = make(map[uint64]*turnLeg, o.maxParallelTurns) } st.legs[leg.gen] = leg st.mode = ModeThinking return leg, runCtx, cancel } // legLocked 按世代号取该会话的在途路;不存在时返回 nil,表示这次收尾属于一条 // **已经失效**的路(被打断、被取代,或会话从未登记过它)。它取代了旧的 // `st.gen != gen` 判据:真并行下启动第二路不再让第一路失效。 // // 必须在 o.mu 下调用。 func (o *Orchestrator) legLocked(st *sessionState, gen uint64) *turnLeg { if st == nil { return nil } return st.legs[gen] } // legByTurnIDLocked 按权威 turnId 取该会话的在途路;不存在时返回 nil。turnId // 是客户端手上的唯一目标标识(`interrupt.turnId`),因此「这条路还在吗」只能在 // 状态锁下按它回答 —— 已收尾 / 已取消的路会被从 st.legs 摘掉,于是「不存在」 // 与「已经停了」是同一个答案。 // // 必须在 o.mu 下调用。 func (o *Orchestrator) legByTurnIDLocked(st *sessionState, turnID string) *turnLeg { if st == nil || turnID == "" { return nil } for _, leg := range st.legs { if leg.turnID == turnID { return leg } } return nil } // cancelLegsLocked 取消该会话的在途路,返回真的取消了几路。turnID 为空 = 作用于 // **每一路**(design/15 §2.5.4:省略 turnId 的 scope);非空 = 只作用于该 turnId // 的那一路(忙时并行规范草案 §2.3 的目标化打断)。每路各有自己的取消链,因此取消 // 一路绝不影响另一路已发起的工具(I3′)。 // // 必须在 o.mu 下调用。 func (o *Orchestrator) cancelLegsLocked(st *sessionState, turnID string) int { if st == nil { return 0 } canceled := 0 for _, leg := range st.legs { if turnID != "" && leg.turnID != turnID { continue } if leg.cancel != nil { leg.cancel() } canceled++ } return canceled } // busyPayload 组装忙态 / 空闲帧的载荷(design/15 §2.6):既有键(sessionId / mode) // 一个不改,只新增在途路数 activeTurns(整数,含正在收尾的路)。它不含任何内容, // 因此这些帧仍在「状态机信号」白名单内、所有已认证连接都收得到。 func busyPayload(sessionID string, mode Mode, activeTurns int) map[string]any { return map[string]any{"sessionId": sessionID, "mode": mode, "activeTurns": activeTurns} } // pendingAckPayload 组装排队应答的载荷(design/15 §2.6)。既有三个键 // (sessionId / mode / queueDepth)一个不改;只有「显式声明了并行却落回排队」 // 才附 reason,其它情况该键**缺席**,绝不伪造。 func pendingAckPayload(sessionID string, mode Mode, depth int, parallelLimit bool) map[string]any { data := map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth} if parallelLimit { data["reason"] = pendingAckReasonParallelLimit } return data } // legKind 返回这一路在 `turn_begin.data.kind` 里的取值(规范草案 §2.4):服务端 // **实际**如何起它 —— 显式声明并行且照准 ⇒ "parallel",新回合 / 续跑 ⇒ "turn"。 // 它只供展示,客户端的状态机不必依赖它。 func legKind(leg *turnLeg) string { if leg != nil && leg.parallel { return turnBeginKindParallel } return turnBeginKindTurn } // userMessageIDs 取出这一路已落库用户消息的**权威** id(客户端靠它把气泡钉到 // 这一路)。没有落库行(未接历史存储、或该次写入失败 —— 那条失败路径已由 // persistUserMessages 带来源记 Error)时返回**空数组**而不是 nil:契约里 // `messageIds` 是数组,null 会让严格解码的客户端直接报错。 func userMessageIDs(msgs []history.Message) []string { ids := make([]string, 0, len(msgs)) for _, m := range msgs { if m.ID != "" { ids = append(ids, m.ID) } } return ids } // publishLegStart 在**起了一路之后**统一做三件事(design/15 §2.5.9):把刚落库的 // 用户消息按路配对(文档结论回写,design/24 §6.1)、挂本路的语音收尾回调(协同 // C1)、并下发 `turn_begin`。顺序是刻意的:`turn_begin.data.messageIds` 必须是 // **服务端权威的**消息 id(客户端靠它把这些气泡置为「已受理·回答中」并按 turnId // 分桶),因此它只能在落库之后发。 // // ctx 上会先挂上本路的回合标识(design/15 §2.5.9 的两段式第一段):这一刻落库的 // 用户消息随**写入**带上 turn_id,于是它与该路的助手回复同值 —— 客户端重连后靠 // 这一列精确配对「回复 ↔ 提问」,而流帧早就丢了。 // // 这里落库的只有**此刻到达**的那些消息(已播种的转录早就持久了,重追加会重复整个 // 对话),且只写**引用** —— 抽取结论要等回合跑起来才有,由回合结算补进同一列 // (两段式见 session_document_status.go)。 // // 单路与并行路共用这一段,于是两条启动路径的帧序完全同形 —— 客户端只需要实现一 // 遍「turn_begin → 灰态」。 func (o *Orchestrator) publishLegStart(ctx context.Context, sessionID string, leg *turnLeg, messages []model.Message, deviceID, mode, personaID string, activeTurns int) { ctx = withTurnID(ctx, leg.turnID) persisted := o.persistUserMessages(ctx, sessionID, messages, deviceID, mode, personaID) o.rememberTurnDocuments(sessionID, leg.gen, persisted) o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx)) o.emitTurnBegin(sessionID, leg, userMessageIDs(persisted), activeTurns) } // emitTurnBegin 下发一次 `turn_begin`(design/15 §2.5.9 / 规范草案 §2.4):帧壳 // `turnId` 是该路的权威回合标识(与它全部流帧的 turnId 同值 ⇒ 客户端可两级分桶), // data 带 {kind, messageIds, leg, activeTurns}。 // // `messageIds` 为空数组时**仍然发**:客户端的绑定是空操作,而「起了一路就恰好发 // 一帧」是可数的契约(leg / activeTurns 仍有用),按有无消息 id 决定发不发反而会让 // 帧数随存储接线而漂移。 func (o *Orchestrator) emitTurnBegin(sessionID string, leg *turnLeg, messageIDs []string, activeTurns int) { if leg == nil { return } if messageIDs == nil { messageIDs = []string{} } o.emitTurnState(sessionID, leg.turnID, EventTurnBegin, map[string]any{ "kind": legKind(leg), "messageIds": messageIDs, "leg": leg.leg, "activeTurns": activeTurns, }) }