fix(flow): 时间轴两处诚实性缺陷(X1)

① round 号跨重启被复用 ⇒ 时间轴把多个真实轮次并成一块(真机实测 web 的 r:1 挤了 4 路、
   startedAt 跨 13:52→21:05)。修法:发号前从落盘播种(MaxRound,json_extract + json_valid 挡坏行),
   每会话每进程只播一次、失败只 WARN 且不重试、并发首轮共享一次读;接口形状零变化
② truncated 一个字段兼两个意思 ⇒ 一页没拉完时前端会说「更早的轮次已过保留期」(假话)。
   新增 retentionBoundaryReached(= 保留期启用 且 已翻到底 且 现存最早一轮 round>1 ⇒ 确有轮次被清扫),
   truncated 语义一字未改;盲区如实登记(整段被清扫会说少、最早一轮落盘失败可能多说)
3 个变异自检(不落账/floor>0/去掉 hasMore 守卫)
This commit is contained in:
AskaEth committed 2026-10-06 21:16:21 +08:00
1 parent b36798265a
commit 2bf3b536dd
9 files changed
+820 -12

No files matched your search

+16 -1
View File
@@ -82,9 +82,13 @@ type FlowPublisher struct {
// (`{会话}-{序号}[#{路}]`)。用序号而不是轮次号做 id 的第二个分量,是为了让
// 「同一轮里的第二条路」也拿到一个全新的 id —— 否则两路会撞同一个
// `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
//
// **跨重启**:本进程第一次给某会话发号之前,`ensureRoundSeeded` 会把它播到该会话
// **已落盘**的最大轮次号之上(见 flow_roundseed.go),因此重启不会重用轮次号。
seq map[string]int
// rounds 是每会话当前的**轮次号**(= 该轮第一条路的建图序号)。同一轮的各条路
// 共享它,客户端据此只保留最新一轮的泳道。
// 共享它,客户端据此只保留最新一轮的泳道。它**跨重启单调**(同上:从落盘侧播种),
// 因此时间轴按它分组时不会把重启前后的两轮并成一块。
rounds map[string]int
// roundOf 是「归属桶 → 它属于哪一轮」。
roundOf map[string]int
@@ -92,6 +96,10 @@ type FlowPublisher struct {
// (集合为空),因此它是轮次号推进的唯一依据,也是内存不随路数无界增长的
// 依据(新一轮起时丢掉更早轮次的桶)。
inflight map[string]map[string]bool
// seedDone 是「该会话的播种动作已完成」的标记(成功或放弃,见 flow_roundseed.go);
// seeding 是正在播种的会话 → 完成信号,使同一会话的并发首轮等这一次播种。
seedDone map[string]bool
seeding map[string]chan struct{}
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
record RoundRecorder
@@ -114,6 +122,8 @@ func NewFlowPublisher(publish func(subject string, payload any), allow func(sess
rounds: make(map[string]int),
roundOf: make(map[string]int),
inflight: make(map[string]map[string]bool),
seedDone: make(map[string]bool),
seeding: make(map[string]chan struct{}),
}
for _, opt := range opts {
if opt != nil {
@@ -161,11 +171,15 @@ func (p *FlowPublisher) StartRoundWithTurnID(ctx context.Context, owner, inputTe
// 轮次号只在**上一轮的路全部收尾**之后推进(inflight 为空),因此「同一条消息
// 引出的两路」共享同一个轮次号;新一轮开始时丢掉更早轮次的桶,内存因此不随路数
// 无界增长(图的历史在落盘里,见 flowgraph 包)。
//
// 发号之前先 `ensureRoundSeeded`:本进程第一次给该会话发号时从落盘侧播种,使轮次号
// **跨重启单调**(否则重启后从 1 重来,会把库里的旧轮次并进同一块,见 flow_roundseed.go)。
func (p *FlowPublisher) startRound(owner, turnID, inputText string) {
session, _ := splitFlowOwner(owner)
if session == "" || owner == "" || !p.allowed(session) {
return
}
p.ensureRoundSeeded(session)
p.mu.Lock()
defer p.mu.Unlock()
round := p.beginRoundLocked(session, owner)
@@ -184,6 +198,7 @@ func (p *FlowPublisher) startRound(owner, turnID, inputText string) {
}
// beginRoundLocked 分配(或沿用)本轮次号并清掉更早轮次的桶。调用方必须持有 p.mu。
// 计数器由 `ensureRoundSeeded` 在发号之前播种(跨重启单调,见 flow_roundseed.go)。
func (p *FlowPublisher) beginRoundLocked(session, owner string) int {
p.seq[session]++
if len(p.inflight[session]) == 0 {