Files
Cyrene_v2/internal/services/gateway/session_turn_reply.go
T
AskaEth 89e13cf53d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,loop): 忙时并行回合基础管线(I1′ 有条件放宽)
- 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/**)
2026-10-05 09:18:50 +08:00

85 lines
4.6 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"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
)
// 本文件归属「回合收尾必须留下可见回复」这条不静默铁律(切片 KB1)。
//
// 缺陷形状(安卓侧实测 E2E-RETRY,纯文本回合):模型连续两次调用一个失败的
// `knowledge` 工具,随后给出一段**空回复**(只有思维链、没有正文)—— 循环按
// 「正常收尾」落定,Result.Text 为空,而助手回复的落库对空文本是静默空操作,
// 于是历史里只剩那条用户消息:客户端两张「工具调用失败」卡片之后直接回空闲,
// 既没有回复、也没有错误横幅。
//
// 同类静默出口还有两个,全都在这里收敛:
// - 工具循环耗尽轮次预算(loop 返回 StopReasonMaxRounds 且 Text 为空);
// - 回合内部报错(Run 返回 nil Result + error:模型 / 工具 / 编排报错)。
//
// 保证是:**归本代次、且不是被用户打断的回合,历史里一定有一条可见的 assistant
// 消息** —— 没有真实文本时补一条按结束原因区分的诚实说明。落库仍走
// persistAssistantReply(同一条写入 + message_persisted 广播路径),因此历史接口、
// 跨端同步与客户端渲染都与真实回复完全一致,不需要任何新契约。
//
// 三类**不补**的情形(边界,与既有语义不冲突):
// - 被 interrupt 取消的回合:用户意图就是「停下」,此刻补一条「我出问题了」
// 反而违背那条指令;
// - 已经失效的路(被 interrupt 取消后又被清空、或会话从未登记过它):那次收尾
// 不属于当前会话状态,补消息会落到别人的回合里;
// - 静默内部流程(backthink、子代理):它们各自驱动 loop,从不经过本编排器,
// 因而天然不受这条保证影响(它们的产出只进内部状态,不进聊天历史)。
// 「空回复 / 轮次用尽 / 安全网终止 / 内部报错该说什么」由共享包
// internal/runtime/turnreply 统一给出(IM 渠道桥走另一条编排路径,两边必须是
// 同一份诚实说明;各写一套迟早漂移)。本文件只负责判定**这次收尾该不该落库**。
// turnIsCurrent 报告该路是否仍是该会话的**在途路**。已失效的路(被打断、被
// interrupt-all 清空、或会话从未登记过它)意味着这次收尾属于**一条已经不存在的
// 回合**,不该再往历史里写。
//
// 判据从旧的「世代号等于会话当前代次」改成「这条路的世代号还在 st.legs 里」:
// 真并行下启动第二路会给它分配一个新世代号,但**绝不会**让第一路失效
// (design/15 §2.5.9 —— 这正是旧的会话级单例代次做不到的那件事)。
func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
o.mu.Lock()
defer o.mu.Unlock()
return o.legLocked(o.states[sessionID], gen) != nil
}
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
// 步)。两条分支:
//
// - 有真实文本:逐字落库,行为与加入本保证之前完全一致(被取消/陈旧代次的
// 回合也照旧落 —— 内容是真的,与「不静默」无关);
// - 没有可见文本(空回复 / 轮次用尽 / 内部报错 / 只剩安全网的内部文案):只有
// 「归本代次且不是被取消」才补一条诚实说明;其余情形返回原样、什么都不写。
//
// 补过文案时它就地改写 res.Text 并把它交还调用方,因此**续跑尾部**(st.tail)也
// 带着这条说明 —— 队列里的下一条消息不会看到一个「什么都没说」的上下文。res 为
// nil(回合报错)时它新建一个只带文案的 Result,调用方的 nil 语义不变(异步路径
// 丢弃返回值,同步路径在此之前已按错误返回)。
func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) *loop.Result {
if o.history == nil {
return res
}
text := turnreply.Text(res)
if res != nil && res.Text == text {
// 真实文本:逐字落库,行为与加入本保证之前完全一致。
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
return res
}
if errors.Is(runErr, context.Canceled) || !o.turnIsCurrent(sessionID, gen) {
return res
}
if res == nil {
res = &loop.Result{}
}
res.Text = text
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
return res
}