feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口①:「一回合最多 5 个附件、落库只留 1 个」——document 列语义升级为 ≤5 个引用的**数组**(列名/字段名不变、无需迁移,旧单对象行可读回);落库收集全部引用、转录还原全部部件。

- 缺口②:抽取结论 status/chars/truncated 经 completeTurn 回合后**回写**该回合用户消息的同一列(id 按代次配对,忙时消息挂 pendingMsg.persisted 并在 drain 时以新 gen 重新配对);历史 DTO 改为 **documents: []**(omitempty ⇒ 0 个附件时省略)。

- 防御:编/解码超 5 都报 ErrTooManyDocuments(报错、绝不裁剪);坏列走既有不静默路径(转录 WARN + DTO unreadable 最小引用);>5 上限值被独立用具例钉死。

- 顺手修 S3-B 遗留的函数越界:submitInternal 81 → 79 行(播种块抽成 seedTurnLocked);session_orchestrator.go 484 行。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 12 文件)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿、gateway+history 437 PASS;变异独立复现(只留第一个附件 ⇒ 2 条红,含 HTTP 端到端 want 3 条)。

- 遗留(已登记):chat_documents_test.go 510 行(S3-B 遗留越界,本次未拆);中断/被取代的回合不回写结论;回写后不补发实时帧(status 需重新拉取/回放可见)。
This commit is contained in:
AskaEth committed 2026-10-04 17:42:16 +08:00
1 parent 20c1edb36a
commit 5b2b385524
12 files changed
+1255 -166

No files matched your search

@@ -142,6 +142,10 @@ type sessionState struct {
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
// 不稳定;按 seq 认领已注入的消息,才不会被同一时刻的追加或清空误伤。
pendingSeq uint64
// turnDocs 是本回合已落库的用户消息(id + 只有引用的 document 列),供回合
// 结算时把抽取结论回写进那一列(design/24 §6.1)。它按代次配对,因此被中断/
// 取代的陈旧回合读不到本回合的条目。见 session_document_status.go。
turnDocs turnDocuments
}
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
@@ -161,6 +165,10 @@ type pendingMsg struct {
// 它只在状态锁下分配,是转向注入「注入=已消费」认领的依据:队列会被追加、
// 被弹出、被中断清空,序号却能唯一定位这一条。
seq uint64
// persisted 是这条待处理回合已落库的用户消息,供它的续跑回合结算时回写
// 文档抽取结论(design/24 §6.1):它在入队那一刻就落了库,而那时抽取还没
// 发生。见 session_document_status.go。
persisted []history.Message
}
// Orchestrator 拥有每会话的消息状态机。它对并发安全
@@ -263,11 +271,14 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
ctx = withClientMsgHint(ctx, a.hint)
if st.mode != ModeIdle {
st.pendingSeq++
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: st.pendingSeq})
seq := st.pendingSeq
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: seq})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
// 落库在锁外(慢操作)完成,随后把它挂回那条待处理回合:抽取结论要等
// 这个回合真正跑起来才有,只能在它的续跑结算时回写(design/24 §6.1)。
o.attachPendingDocuments(sessionID, seq, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth, MessageID: a.hint.MessageID}, nil
}
@@ -279,16 +290,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
// 消息不会抢在被取消回合落定旧快照之前开跑(session_turn_fence.go)。
fence := newTurnFence(st.fence.done)
st.fence = fence
// 新回合延续该会话的对话:持久化转录被播种在
// 刚发送的消息之前(design/15 §2.2/§4,design/22
// §7-5),且其助手话语保持说话时的角色。该读取
// 在此发生,在下面 persistUserMessages 写入新消息之前,因此刚
// 发送的消息绝不在上下文中重复。没有历史(或空会话)的
// 存储让该回合只使用调用方给出的消息。它是在状态锁下
// 的有界索引读取(transcriptContextCount 行),而状态锁
// 从不会被运行中的回合持有,因此它无法把 Submit 堵在回合后面。
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
turn := o.seedTurnLocked(ctx, st, sessionID, messages)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
o.mu.Unlock()
@@ -299,9 +301,10 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, gen))
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
// 只有此刻到达的消息会被持久化:已播种的转录
// 已经持久,重新追加它会重复整个对话。
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
// 只有此刻到达的消息会被持久化(已播种的转录已持久,重追加会重复整个对话),
// 且落库只写**引用** —— 抽取还发生在后面,结论由回合结算时补进同一列
// (两段式见 session_document_status.go)。
o.rememberTurnDocuments(sessionID, gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
if sync {
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, deviceID, msgMode, a.hint.MessageID)
@@ -351,8 +354,13 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
//
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本回合用户消息的
// document 列里此前只有引用,装配期解析出的 status/chars/truncated 在这里补上
// —— 见 session_document_status.go 的两段式说明。
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
o.persistDocumentStatus(sessionID, gen, res)
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
@@ -378,6 +386,9 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
// 的回合无需再等任何在途回合:围栏只负责放行它后面的那一个。
fence := newTurnFence(nil)
st.fence = fence
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
// 记到新代次上(design/24 §6.1)。
st.turnDocs = turnDocuments{gen: gen, msgs: next.persisted}
depth := len(st.pending)
resume := append([]model.Message(nil), st.tail...)
env := next.env
@@ -471,20 +482,3 @@ func (o *Orchestrator) notifyPersisted(msg history.Message) {
o.onPersisted(msg)
}
}
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}