package gateway import ( "context" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本文件归属消息行上的**回合标识**(turnId,design/15 §2.5.9)的持久化面:把编排器 // 为每一路生成的权威 id 落进 chat_messages.turn_id(迁移 0018),使客户端在重连 / // 回放时仍能精确配对「回复 ↔ 提问」—— 流帧是易失的,历史行不是。 // // 它从 session_persist.go 拆出来,使那个文件继续留在「单文件 ≤500 行」的硬约束内 // (与 session_events.go / session_document_status.go 同一理由)。 // // 写入是**两段式**(与 document 列同一手法): // - 落库时所属路已经存在(新起的一路 + 它的助手回复)⇒ 随 INSERT 写入:用户消息 // 走 ctx 通道(withTurnID,与 deviceID / mode / 幂等键同一种机制),助手回复走 // 显式参数(persistAssistantReply 的可变尾参); // - 落库时还不知道所属路(忙时入队、被转向吃进)⇒ 由**实际处理它的那一路**在起跑 / // 消费之后用 assignTurnID 补写。 // // 空值一律是空操作:省略标识的路径(既有调用点、未接线该能力的构建)行为与加入本列 // 之前逐字相同。 // turnIDContextKey 是携带本路回合标识的 context 键(design/15 §2.5.9)。它由 // 「起了一路」的那一刻挂上,使这一路落的用户消息带上它。 type turnIDContextKey struct{} // withTurnID 返回携带本路回合标识的 context。空 turnID 是空操作:没有回合标识的 // 路径(未接编排器的旧路径、存储替身)在 ctx 上不留任何痕迹。 func withTurnID(ctx context.Context, turnID string) context.Context { if turnID == "" { return ctx } return context.WithValue(ctx, turnIDContextKey{}, turnID) } // turnIDFrom 返回 ctx 携带的本路回合标识,未设置时返回 ""。 func turnIDFrom(ctx context.Context) string { v, _ := ctx.Value(turnIDContextKey{}).(string) return v } // firstTurnID 返回可选回合标识里的第一个非空值,没有时返回 ""。它服务 // persistAssistantReply 的可变形参:省略与空值等价(都不写这一列)。 func firstTurnID(turnID []string) string { for _, id := range turnID { if id != "" { return id } } return "" } // turnIDWriter 是 HistoryStore 的一个**可选**更窄视图(与 clientMsgIDReader / // documentStatusWriter 同一做法):按消息 id 批量落定 turn_id。未实现该视图的 // 存储替身或后端退回「不补写」并留下带来源的 WARN —— 补写是增益,绝不能反过来 // 卡住会话。 type turnIDWriter interface { SetMessagesTurnID(ctx context.Context, ids []string, turnID string) error } // turnIDLogSource 是回合标识补写的日志来源标签,使这条冷路径上的 WARN 能被追溯到 // 具体子功能(AGENTS §10 的「日志必须标来源」)。 const turnIDLogSource = "gateway/session-persist" // turnIDOfLeg 返回该会话在途路 gen 的权威回合标识;该路已不在途(被打断清空、 // 已被取代、会话里没有它)时返回 ""。调用方用它把「这一刻算数的这一路」翻译成 // 要落库的标识 —— 它**不得**在持有 o.mu 时调用(本函数自己取锁)。 // // ⚠️ 它有意**不用于**任何已经拿到 `*turnLeg` 的地方:InterruptScope(all) 会把 // st.legs 整个清掉,而被打断那一路的助手文本仍要按原样落库(design/15 §3 的 // 「内容是真的」),那时按 gen 回查会拿到空串、把配对丢掉。凡是手里有 leg 的 // 调用点都传 `leg.turnID`(出生后不可变),只有确实只拿得到 gen 的地方才走本函数。 func (o *Orchestrator) turnIDOfLeg(sessionID string, gen uint64) string { o.mu.Lock() defer o.mu.Unlock() if leg := o.legLocked(o.states[sessionID], gen); leg != nil { return leg.turnID } return "" } // assignTurnID 把一组已落库消息的 turn_id 落定为 turnID —— 两段式写入的第二段。 // 它服务「落库那一刻还不知道自己属于哪一路」的消息:忙时入队(它的路要等轮到它 // 才起)与**被转向吃进**的消息(它由那个在飞的路回答)。两处都在路确定之后调用 // 本函数,因此这些行最终带上的正是**实际处理它们的那一路**的标识。 // // 空 turnID、空消息列表、未接存储都直接返回(没有标识可落,也就没有写入)。存储 // 未实现该可选视图时同样返回 —— 但**写库失败绝不静默**:打一条带来源的 WARN, // 日志只带会话、条数与错误,不带正文。写入用全新的后台 context(与 // persistAssistantReply / persistDocumentStatus 同一理由):被取消回合的 ctx // 绝不该中断这次补写。 // // 它绝不改写已经带标识的行(存储侧只写给仍为空串的行,先到先得),因此重复调用 // 与「两条路径都想认领同一行」都不会让配对漂移。 // // 它必须在**状态锁之外**调用:它写库(那是慢操作)。握着 o.mu 的调用点要先解锁。 func (o *Orchestrator) assignTurnID(sessionID, turnID string, msgs []history.Message) { if o.history == nil || turnID == "" || len(msgs) == 0 { return } writer, ok := o.history.(turnIDWriter) if !ok { return } ids := make([]string, 0, len(msgs)) for _, m := range msgs { if m.ID != "" { ids = append(ids, m.ID) } } if err := writer.SetMessagesTurnID(context.Background(), ids, turnID); err != nil { logger.WithSource(o.log, turnIDLogSource).Warn("turn id not persisted", "err", err, "session", sessionID, "turn", turnID, "messages", len(ids)) } }