Files
Cyrene_v2/internal/services/gateway/session_document_status.go
T
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

199 lines
8.0 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 列。空列表是空操作(这条回合没有落库任何
// 用户消息,也就没有结论可补)。
func (o *Orchestrator) rememberTurnDocuments(sessionID string, gen uint64, msgs []history.Message) {
if len(msgs) == 0 {
return
}
o.mu.Lock()
defer o.mu.Unlock()
if st := o.states[sessionID]; st != nil {
st.turnDocs = 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 取走与 gen 配对的已落库用户消息并清空记录。第二个返回值
// 报告这次代次是否仍是当代:非当代说明这是一次陈旧回合的迟到结算(中断/取代),
// 调用方据此静默返回 —— 迟到的结算是正常竞态,不是缺陷。
func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]history.Message, bool) {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil {
return nil, true
}
if st.turnDocs.gen != gen {
return nil, false
}
msgs := st.turnDocs.msgs
st.turnDocs = 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
}