Files
Cyrene_v2/internal/services/gateway/session_parallel.go
T
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

363 lines
19 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"
"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,
})
}