feat(gateway): 接线 chat_messages.turn_id 写入(A–D 四点齐)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

A 新起一路:publishLegStart 在 persistUserMessages 之前 withTurnID(ctx, leg.turnID)
(该行随 ef3dcb6 已进 HEAD)。
B 队首续跑路:completeTurn 在 o.mu.Unlock() 之后 assignTurnID(sessionID, runLeg.turnID, next.persisted)。
C 被 steer 吃进:consumePending 认领成功后 assignTurnID(sessionID, s.turnID, steerRows(candidates))。
D 助手回复:persistTurnReply 增 turnID 形参,completeTurn 传 leg.turnID —— 刻意**不用** turnIDOfLeg:
interrupt scope=all 会清空 st.legs,而被打断那一路已产出的正文仍要照原样落库(内容是真的),
按 gen 回查只能拿到空串 ⇒ 配对丢失。
删掉 history_turnid_test.go 的 t.Skip:TestSubmitWiresTurnIDForBothRows 真跑并通过。
新增 session_turnid_wiring_test.go(真 SQLite 内存存储 + 真编排器 + 可逐路放行 runner):
单路两条同值 / 并行两路各自同值且两两不同 / 续跑补写(先钉「补写前为空」)/ steer 补写(用该路自己的转向源)/
TestAssistantTurnIDSurvivesInterruptAll(钉住 D 的取值来源)。
变异 5 条(A/B/C/D 各自 + D′ 改回 turnIDOfLeg —— 只有那条守门用例红,证明它守的正是这个取舍)。
诚实边界:turnIDOfLeg 现无生产调用方(保留为兜底);先到先得竞态未构造对抗用例;
IM 渠道桥不走本编排器 ⇒ 其行 turn_id 留空属预期(契约里键缺席)。
This commit is contained in:
AskaEth committed 2026-10-05 10:00:17 +08:00
1 parent 3589f4b14f
commit d804c4e675
5 files changed
+281 -10

No files matched your search

@@ -113,6 +113,10 @@ func (s *steerTurn) Steer(ctx context.Context, req loop.SteerRequest) []model.Me
if !o.consumePending(sessionID, s.gen, candidateSeqs(candidates)) {
return nil
}
// 被吃进的消息在落库那一刻还不知道属于哪一路(那时它还在排队),因此认领成功
// 之后立刻按本路补写 turn_id(两段式第二段,design/15 §2.5.9):它与本路的
// 助手回复同值,客户端才能把灰态气泡与正确的回复配到一起。
o.assignTurnID(sessionID, s.turnID, steerRows(candidates))
out := make([]model.Message, 0, len(candidates))
for _, c := range candidates {
s.consumed[c.row.ID] = true
@@ -195,6 +199,17 @@ func candidateSeqs(cs []steerCandidate) map[uint64]bool {
return seqs
}
// steerRows 把候选消息收成它们**已落库的转录行**,供按路补写 turn_id 用:这些行被
// 吃进之前都是以「还不知道属于哪一路」的状态落库的(那时列里是空的),因此这一刻
// 才谈得上给它们一个归属。
func steerRows(cs []steerCandidate) []history.Message {
rows := make([]history.Message, 0, len(cs))
for _, c := range cs {
rows = append(rows, c.row)
}
return rows
}
// pendingSnapshot 在状态锁下取该会话待处理队列的一份副本(世代不符时返回 nil:
// 那条路已失效,例如被打断、被取代或会话已清空)。取副本而不是
// 持锁做转录读取,是为了不让有界存储读取堵住 Submit。