Files
Cyrene_v2/internal/services/gateway/session_document_status.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

201 lines
8.3 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"
)
// 本文件归属**文档抽取结论的回写闭环**(design/24 §4.4/§6.1,S3.5 缺口②)。
//
// 用户消息在回合**开始前**落库(session_persist.go 的 persistUserMessages),
// 那时文档抽取还没发生,document 列里只有引用、没有结论。抽取结论要等回合跑
// 起来(loop 的装配期解析)才存在,于是本文件负责在**回合结束后**把它补回同一
// 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。
//
// 三条纪律:
// - 只补结论(status/chars/truncated 与 unreadable 的 reason),身份与大小逐字保留;
// - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造);
// - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。
//
// 它从 session_persist.go 拆出来,使那两个文件都留在「单文件 ≤500 行」的硬约束
// 内(与 session_events.go / session_tail.go 同一理由)。
// documentStatusLogSource 是文档状态回写的日志来源标签,使这条冷路径上的
// WARN 能被追溯到具体子功能(AGENTS §10 的「日志必须标来源」)。
const documentStatusLogSource = "gateway/session-persist"
// turnDocuments 配对一次回合同它已落库的用户消息与所属代次(gen)。代次是
// 必须的:被打断或被新回合取代的陈旧回合在结算时不该去写别人的那一行。
type turnDocuments struct {
gen uint64
msgs []history.Message
}
// documentStatusWriter 是 HistoryStore 的一个**可选**更窄视图(与
// clientMsgIDReader / transcriptReader 同一做法):按消息 id 更新 document 列。
// 未实现该视图的存储替身或后端退回「不回写」并留下带来源的 WARN,不因此让
// 回合失败 —— 状态回传是增强,绝不能反过来卡住会话。
type documentStatusWriter interface {
UpdateMessageDocument(ctx context.Context, id, document string) error
}
// rememberTurnDocuments 记下本路已落库的用户消息(按世代号配对),供回合结束
// 后把抽取结论回写进它们的 document 列。空列表是空操作(这条回合没有落库任何
// 用户消息,也就没有结论可补)。
//
// 它按**路**配对(design/15 §2.5.9「历史配对」升为路级):并行时两路各自持有
// 自己那几条已落库的用户消息,任何一路结算都只回写自己那几行,绝不会把结论
// 写进另一路的行。路已失效(被打断清空)时什么都不做 —— 这次迟到结算不属于
// 当前状态。
func (o *Orchestrator) rememberTurnDocuments(sessionID string, gen uint64, msgs []history.Message) {
if len(msgs) == 0 {
return
}
o.mu.Lock()
defer o.mu.Unlock()
if leg := o.legLocked(o.states[sessionID], gen); leg != nil {
leg.docs = turnDocuments{gen: gen, msgs: msgs}
}
}
// attachPendingDocuments 把刚落库的用户消息挂到队列里 seq 所指的那条待处理回合
// 上,使它的续跑回合结算时能回写结论(它在入队那一刻就落了库,而那时还没有
// 任何结论)。
//
// 队列里已经找不到那条(被中断清空、已被弹出或已被续跑消费)时什么都不做:
// 那一行的结论没有谁能再补,也没有必要为此刷一条 WARN —— 它不是缺陷,是中断
// 的正常后果。
func (o *Orchestrator) attachPendingDocuments(sessionID string, seq uint64, msgs []history.Message) {
if len(msgs) == 0 {
return
}
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil {
return
}
for i := range st.pending {
if st.pending[i].seq == seq {
st.pending[i].persisted = msgs
return
}
}
}
// takeTurnDocuments 取走**本路**(世代号配对)已落库的用户消息并清空记录。
// 第二个返回值报告这条路是否仍然在途:不在途说明这是一次失效路的迟到结算
// (被中断清空 / 被取代),调用方据此静默返回 —— 迟到的结算是正常竞态,不是缺陷。
func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]history.Message, bool) {
o.mu.Lock()
defer o.mu.Unlock()
leg := o.legLocked(o.states[sessionID], gen)
if leg == nil {
return nil, false
}
msgs := leg.docs.msgs
leg.docs = turnDocuments{}
return msgs, true
}
// persistDocumentStatus 在回合结束后把装配期解析到的文档结论回写进该回合用户
// 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库
// 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。
//
// 回写的是 status/chars/truncated 与 unreadable 的原因文案(reason,契约
// v0.63)—— 之后所有读取面(历史接口 / 握手回放)拿到的是同一份结论。
//
// 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都
// **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、
// 条数与错误,**绝不带正文**,也不带列内容。
//
// 写入使用全新的后台 context(与 persistAssistantReply 同一理由):被取消回合
// 的 ctx 绝不该中断这次补写。
func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res *loop.Result) {
if res == nil || len(res.Documents) == 0 {
return
}
log := logger.WithSource(o.log, documentStatusLogSource)
if o.history == nil {
log.Warn("document status not persisted", "reason", "no history store", "session", sessionID, "documents", len(res.Documents))
return
}
writer, ok := o.history.(documentStatusWriter)
if !ok {
log.Warn("document status not persisted", "reason", "history store has no document update path", "session", sessionID, "documents", len(res.Documents))
return
}
msgs, current := o.takeTurnDocuments(sessionID, gen)
if !current {
return
}
if len(msgs) == 0 {
log.Warn("document status not persisted", "reason", "no persisted user message for this turn", "session", sessionID, "documents", len(res.Documents))
return
}
ctx := context.Background()
for _, m := range msgs {
if m.Document == "" {
continue
}
merged, changed, err := mergeDocumentStatus(m.Document, res.Documents)
if err != nil {
log.Warn("document status merge", "err", err, "session", sessionID, "message", m.ID)
continue
}
if !changed {
continue
}
if err := writer.UpdateMessageDocument(ctx, m.ID, merged); err != nil {
log.Warn("document status update", "err", err, "session", sessionID, "message", m.ID)
}
}
}
// mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到
// 的补上 status/chars/truncated/reason,没匹配到的原样保留(那意味着本回合没有
// 解析到它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
// 没变化就不必写库、也不必刷一条「什么都没变」的日志。
//
// reason 与 status 同批搬运(契约 v0.63):抽取结论的这三项 + 原因文案是同一个
// 回合的同一份事实,分两次写会让客户端看到「unreadable 但没有理由」的中间态。
//
// 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。
func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) {
refs, err := history.DecodeDocuments(raw)
if err != nil {
return "", false, err
}
conclusion := make(map[string]history.DocumentRef, len(resolved))
for _, ref := range resolved {
if ref.Name != "" {
conclusion[ref.Name] = ref
}
}
changed := false
for i := range refs {
got, ok := conclusion[refs[i].Name]
if !ok {
continue
}
if refs[i].Status == got.Status && refs[i].Chars == got.Chars &&
refs[i].Truncated == got.Truncated && refs[i].Reason == got.Reason {
continue
}
refs[i].Status, refs[i].Chars, refs[i].Truncated, refs[i].Reason =
got.Status, got.Chars, got.Truncated, got.Reason
changed = true
}
if !changed {
return raw, false, nil
}
merged, err := history.EncodeDocuments(refs)
if err != nil {
return "", false, err
}
return merged, true, nil
}