Files
AskaEth 2bf3b536dd 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 守卫)
2026-10-06 21:16:21 +08:00

312 lines
11 KiB
Go
Raw Permalink 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 flowgraph
// 本文件钉住**时间轴读侧**(design/19 §7;切片 W1):多轮按时间正序取回、一轮含其
// **每一路**、`before` 游标向更早翻页不重不漏、没有轮次号的老行各自成组。
//
// 断言的是**库里读回来的事实**(几轮、每轮几路、次序、`hasMore`),不按日志断言 ——
// 与 flowgraph_test.go / flowgraph_lane_test.go 同一口径。
import (
"context"
"fmt"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// timelineGraph 用真建图器造一条路的收尾图:round 是会话轮次号(同轮的多路共享),
// seq 是会话级建图序号(每条路必不同,决定图 id),lane 是这一路的权威回合标识。
func timelineGraph(session string, round, seq int, lane string, started time.Time) *trace.FlowGraph {
g := trace.NewRunGraph(session)
g.SetTurnID(lane)
g.SetSeq(seq)
g.Start(round, started)
g.SeedInput("输入-" + lane)
g.SeedOutput("回复-" + lane)
return g.Snapshot()
}
// recordTimelineRound 落一条时间轴夹具(失败即 Fatal)。
func recordTimelineRound(t *testing.T, st *SQLStore, g *trace.FlowGraph) {
t.Helper()
if err := st.RecordRound(g); err != nil {
t.Fatalf("落盘 %s: %v", g.ID, err)
}
}
// TestTimeline_GroupsLanesByRound_正常 是本文件的核心断言:三轮按**时间正序**返回,
// 中间那一轮的**两路**并进同一轮(`lanes` 两条),而不是被拆成两轮、也不是只剩一路。
func TestTimeline_GroupsLanesByRound_正常(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-time.Hour)
recordTimelineRound(t, st, timelineGraph("web", 1, 1, "turn-a", base.Add(30*time.Minute)))
recordTimelineRound(t, st, timelineGraph("web", 2, 2, "turn-b", base.Add(40*time.Minute)))
recordTimelineRound(t, st, timelineGraph("web", 2, 3, "turn-c", base.Add(40*time.Minute).Add(time.Second)))
recordTimelineRound(t, st, timelineGraph("web", 3, 4, "turn-d", base.Add(50*time.Minute)))
rounds, more, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if more {
t.Fatal("hasMore = true,但库里只有这三轮")
}
if len(rounds) != 3 {
t.Fatalf("轮数 = %d, want 3(同一轮的两路属**同一轮**): %+v", len(rounds), roundKeys(rounds))
}
for i, want := range []string{"r:1", "r:2", "r:3"} {
if rounds[i].Key != want {
t.Fatalf("rounds[%d].Key = %q, want %q(时间正序)", i, rounds[i].Key, want)
}
}
for i, want := range []int{1, 2, 3} {
if rounds[i].Round != want {
t.Fatalf("rounds[%d].Round = %d, want %d", i, rounds[i].Round, want)
}
}
if got := len(rounds[1].Lanes); got != 2 {
t.Fatalf("第 2 轮的 lanes = %d, want 2(两路都在): %+v", got, rounds[1].Lanes)
}
if !rounds[1].StartedAt.Equal(base.Add(40 * time.Minute)) {
t.Fatalf("第 2 轮开始时刻 = %v, want %v(取最早那一路)", rounds[1].StartedAt, base.Add(40*time.Minute))
}
if rounds[0].Lanes[0].ID != "web-1#turn-a" {
t.Fatalf("第 1 轮的路 id = %q, want web-1#turn-a", rounds[0].Lanes[0].ID)
}
}
// TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界 钉住分页:`limit=2` 逐页向更早翻,
// 各页**不重不漏**、`hasMore` 如实、翻到底后是诚实空态。
func TestTimeline_BeforeCursorPagesWithoutGapOrDup_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-10 * time.Hour)
for round := 1; round <= 5; round++ {
started := base.Add(time.Duration(round) * time.Hour)
recordTimelineRound(t, st, timelineGraph("web", round, round, fmt.Sprintf("turn-%d", round), started))
}
seen := map[string]int{}
before := time.Time{}
wantPages := [][]string{
{"r:4", "r:5"},
{"r:2", "r:3"},
{"r:1"},
{},
}
wantMorePages := []bool{true, true, false, false}
for page, want := range wantPages {
rounds, more, err := st.Timeline(ctx, "web", 2, before)
if err != nil {
t.Fatalf("第 %d 页 Timeline: %v", page+1, err)
}
if got := roundKeys(rounds); !sameKeys(got, want) {
t.Fatalf("第 %d 页轮次 = %v, want %v", page+1, got, want)
}
if more != wantMorePages[page] {
t.Fatalf("第 %d 页 hasMore = %v, want %v", page+1, more, wantMorePages[page])
}
if len(rounds) == 0 {
continue
}
for _, r := range rounds {
seen[r.Key]++
}
before = rounds[0].StartedAt
}
for round := 1; round <= 5; round++ {
if key := fmt.Sprintf("r:%d", round); seen[key] != 1 {
t.Fatalf("轮 %s 出现 %d 次, want 1(翻页不重不漏;全部=%v)", key, seen[key], seen)
}
}
}
// TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界 钉住老行的诚实口径:图上没有轮次号
// (`round` 键缺席)时**每张图自成一组**,绝不把它们猜成同一轮,`Round` 如实为 0
// (调用方据此让线上 JSON 出 `null`)。
func TestTimeline_LegacyRowWithoutRoundIsOwnGroup_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
// 手工造两张「老图」:Round 为 0(= 键缺席)、图 id 不同。
for i, id := range []string{"web-old-1", "web-old-2"} {
g := &trace.FlowGraph{
ID: id,
SessionID: "web",
StartedAt: testNow().Add(-time.Duration(10-i) * time.Minute),
EndedAt: testNow(),
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
}
recordTimelineRound(t, st, g)
}
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if len(rounds) != 2 {
t.Fatalf("轮数 = %d, want 2(无轮次号的行各自成组,绝不猜)", len(rounds))
}
if rounds[0].Key != "t:web-old-1" || rounds[1].Key != "t:web-old-2" {
t.Fatalf("分组键 = %q/%q, want t:web-old-1 / t:web-old-2", rounds[0].Key, rounds[1].Key)
}
for i, r := range rounds {
if r.Round != 0 {
t.Fatalf("rounds[%d].Round = %d, want 0(未知,不得冒充真实轮次)", i, r.Round)
}
}
}
// TestTimeline_StartFallsBackToCreatedAt_边界 钉住时间回落:图上的 `startedAt` 不可用时,
// 轮次开始时刻取该行的**落盘时刻**(同一条记录里的另一个真实时间字段),而不是零值。
func TestTimeline_StartFallsBackToCreatedAt_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
recordTimelineRound(t, st, &trace.FlowGraph{
ID: "web-9",
SessionID: "web",
Round: 9,
Nodes: []trace.FlowNode{{ID: "input", Label: "你好", Kind: "input"}},
})
rounds, _, err := st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline: %v", err)
}
if len(rounds) != 1 {
t.Fatalf("轮数 = %d, want 1", len(rounds))
}
if !rounds[0].StartedAt.Equal(testNow()) {
t.Fatalf("开始时刻 = %v, want %v(图上没有 startedAt ⇒ 回落落盘时刻)", rounds[0].StartedAt, testNow())
}
}
// TestTimeline_LimitClamped_边界 钉住页大小:缺省 50、超过硬上限 200 即钳住且 `hasMore`
// 如实为 true(绝不静默返回全表)。
func TestTimeline_LimitClamped_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
ctx := context.Background()
base := testNow().Add(-time.Hour)
total := MaxTurnsLimit + 5
for round := 1; round <= total; round++ {
started := base.Add(time.Duration(round) * time.Second)
recordTimelineRound(t, st, timelineGraph("web", round, round, "", started))
}
rounds, more, err := st.Timeline(ctx, "web", MaxTurnsLimit+999, time.Time{})
if err != nil {
t.Fatalf("Timeline(超上限): %v", err)
}
if len(rounds) != MaxTurnsLimit || !more {
t.Fatalf("超上限时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, MaxTurnsLimit)
}
rounds, more, err = st.Timeline(ctx, "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline(缺省): %v", err)
}
if len(rounds) != DefaultTurnsLimit || !more {
t.Fatalf("缺省时 = %d 轮 / hasMore=%v, want %d / true", len(rounds), more, DefaultTurnsLimit)
}
}
// TestTimeline_EmptyAndNilDB_边界 钉住两个空态:没有任何落盘行时返回**空切片**(不是
// nil、更不是错误),未接数据库时如实报 ErrNilDB。
func TestTimeline_EmptyAndNilDB_边界(t *testing.T) {
t.Parallel()
st, _ := newTestStore(t, nil)
rounds, more, err := st.Timeline(context.Background(), "web", 0, time.Time{})
if err != nil {
t.Fatalf("Timeline(空会话): %v", err)
}
if rounds == nil || len(rounds) != 0 || more {
t.Fatalf("空会话 = %+v/%v, want 空切片/false", rounds, more)
}
var nilStore *SQLStore
if _, _, err := nilStore.Timeline(context.Background(), "web", 0, time.Time{}); err == nil {
t.Fatal("nil store 必须如实报错,不得假装「没有轮次」")
}
}
// TestEarlierRoundsSwept_RoundFloorIsTheEvidence_正常 钉住**清扫信号**的判据(切片 X1;
// design/19 §7.1):该会话**完整**的轮次列表里,最早一轮的轮次号 > 1 ⇒ 号更小的轮次曾
// 发过号且已不在库里 ⇒ 「更早的轮次已被保留期清扫删掉」成立;最早一轮就是 1 ⇒ 没有更早
// 的被删,绝不说「已过保留期」。
func TestEarlierRoundsSwept_RoundFloorIsTheEvidence_正常(t *testing.T) {
t.Parallel()
roundsOf := func(rounds ...int) []TimelineRound {
out := make([]TimelineRound, 0, len(rounds))
for _, r := range rounds {
out = append(out, TimelineRound{Key: fmt.Sprintf("r:%d", r), Round: r})
}
return out
}
if EarlierRoundsSwept(roundsOf(1, 2, 3)) {
t.Fatal("最早一轮是第 1 轮 ⇒ 没有更早的被清扫,绝不能说「已过保留期」")
}
if !EarlierRoundsSwept(roundsOf(5, 6)) {
t.Fatal("最早一轮是第 5 轮 ⇒ 号更小的轮次确实发过号、已不在库里 ⇒ 必须为 true")
}
if !EarlierRoundsSwept(roundsOf(3, 7, 12)) {
t.Fatal("乱序/跳号不影响判据:取最小轮次号 > 1 即为 true")
}
// 同一轮的多路(真并行)不改变最早轮次号。
parallel := append(roundsOf(2, 3), TimelineRound{Key: "r:2", Round: 2, Lanes: nil})
if !EarlierRoundsSwept(parallel) {
t.Fatal("同一轮两路时最早的仍是第 2 轮 ⇒ true(多路不该把判据翻成 false)")
}
}
// TestEarlierRoundsSwept_UnknownRoundAndEmptyAreConservative_边界 钉住两条保守口径:
// 轮次号未知的轮次在场(老行 / 在飞路没有号)时判据**不成立**(返回 false,宁少说不多说),
// 空列表同样为 false —— 别把「不知道」说成「被清扫了」。
func TestEarlierRoundsSwept_UnknownRoundAndEmptyAreConservative_边界(t *testing.T) {
t.Parallel()
if EarlierRoundsSwept(nil) || EarlierRoundsSwept([]TimelineRound{}) {
t.Fatal("没有轮次 ⇒ 判据不成立(绝不是「已过保留期」)")
}
mixed := []TimelineRound{
{Key: "t:web-old-1", Round: 0},
{Key: "r:5", Round: 5},
}
if EarlierRoundsSwept(mixed) {
t.Fatal("有轮次号未知的轮次在场 ⇒ 号不可信 ⇒ 必须保守为 false")
}
}
// roundKeys 取出轮次键序列(断言用)。
func roundKeys(rounds []TimelineRound) []string {
out := make([]string, 0, len(rounds))
for _, r := range rounds {
out = append(out, r.Key)
}
return out
}
// sameKeys 报告两个键序列是否逐字相等。
func sameKeys(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}