feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- turnId 由编排器生成、loop Run 复用(硬前提:否则 turn_begin 与流帧各铸一个,客户端两级分桶立即失配)
- StateSink 增带 turnId 的变体(未接线时退化为现签名,不丢状态帧)
- 上行 chat.parallel(WS chat 帧 + REST 三入口同形;省略 = 逐字不变)
- 单会话额度默认 2 / 硬上限 3 / <2 关闭;额度满或未声明 ⇒ 落回既有排队语义
- I1′:checkpoint 归属、历史配对、忙态计数三处从会话级单例升为路级
- session_idle 收紧为「所有在途路收尾且 pending 空」(仅并行时生效;单路逐字不变)
- 预留接口(形状定死、无实现无接线):tool/legfile.go 的 FileClaimer / ErrFileBusy / WithLegID
- 顺手修一个真 bug:并行路 ctx 派生自提交请求作用域,REST 同步入口一写响应就把它连坐杀死(改 context.WithoutCancel 派生)
- 23 个新用例(含 -race -count=3);变异 6 条(turnId 复用 / idle 时点 / 并行升级 / checkpoint 键 / 编排器交 id / pending_ack reason)
- 契约变更点 10 条待监工升 v0.65(本人未改 Docs/**)
This commit is contained in:
AskaEth committed 2026-10-05 09:18:50 +08:00
1 parent 7d2ff74cfc
commit 89e13cf53d
23 files changed
+1921 -275

No files matched your search

@@ -0,0 +1,242 @@
package gateway
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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` 帧的发射、`steer_ack` 的帧壳 turnId、
// 写类工具的文件级互斥、第二路的进行态 system 摘要。预留点见 turnLeg 的字段。
// 并发额度的取值口径(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"
)
// 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})。
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))
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)
o.rememberTurnDocuments(sessionID, leg.gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, mode, env.PersonaID))
o.rememberVoiceTurn(sessionID, leg.gen, voiceReplySinkFrom(ctx))
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 与(并行时的)路标识盖进 runCtx,
// 并把 runCtx 交回调用方 —— 调用方解锁后再起 goroutine。
//
// turnID 由**编排器**生成并由 loop 复用(design/15 §2.5.9 的 ⚠️ 必改项):
// `turn_begin` 帧由编排器在调用 Run 之前发出,若 Run 内部再自己生成一个,
// 客户端按 turnId 两级分桶会立即失配。单路时该 id 仍是每回合全新的随机值,
// 因此对客户端**无可见差异**。
//
// 它同时把会话标成忙(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)
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]
}
// cancelLegsLocked 取消该会话当前**全部**在途路(design/15 §2.5.4:省略 turnId 的
// scope 作用于每一路),返回真的取消了几路。每路各有自己的取消链,因此取消一路
// 绝不影响另一路已发起的工具(I3′)。
//
// 必须在 o.mu 下调用。
func (o *Orchestrator) cancelLegsLocked(st *sessionState) int {
if st == nil {
return 0
}
for _, leg := range st.legs {
if leg.cancel != nil {
leg.cancel()
}
}
return len(st.legs)
}
// 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
}