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

@@ -19,8 +19,13 @@ package gateway
// 严格早于它的轮次」,响应带 `nextBefore` 供客户端原样回带(分页不重不漏的口径由
// `flowgraph.SQLStore.Timeline` 保证并测试)。
// 4. **保留期如实**:过期轮次已被清扫,不在返回值里;响应给 `retentionHours` /
// `retentionEnabled` / `truncated`,由前端据此标注「更早的轮次已过保留期」——
// 绝不静默少一块(design/19 §7.1 的原话)。
// `retentionEnabled` / `truncated` / `retentionBoundaryReached`,由前端据此标注
// 「更早的轮次已过保留期」——绝不静默少一块(design/19 §7.1 的原话)。
// 🔴 **两个字段各说一件事**(切片 X1 修的一处真缺陷):`truncated` 只回答「本次是不是
// 全部」(`hasMore` 或落盘源未接线),`retentionBoundaryReached` 才回答「更早的轮次
// 是不是**确实被保留期清扫掉了**」——后者由 `flowgraph.EarlierRoundsSwept` 给出判据
// (该会话现存最早一轮的轮次号 > 1 ⇒ 号更小的轮次曾落盘、且已被清扫)。混用会让面板
// 对着**仍然可翻**的历史说「已过保留期」,那是假话。
//
// 未接线(内存与落盘**都没接**)时回 `500 flow_unavailable`,与老出口同一码、同一口径:
// 绝不回一份空的 `rounds` 把「图源没接线」伪装成「这台会话没有轮次」。
@@ -103,8 +108,25 @@ type adminFlowTimelineResponse struct {
// HasMore 报告库里**还有更早的轮次**没被本次返回(用 NextBefore 继续取)。
HasMore bool `json:"hasMore"`
// Truncated 报告本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线(只剩内存
// 最近的这一轮)。前端据此如实标注「更早的轮次未显示」,**绝不静默少一块**。
// 最近的这一轮)。它是**分页口径**,不是保留期口径 —— 面板据此标注「更早的轮次未
// 显示」,**绝不静默少一块**。要问「更早的是不是被清扫掉了」请看
// `retentionBoundaryReached`(两个字段各说一件事,切片 X1 修的正是这处混用)。
Truncated bool `json:"truncated"`
// RetentionBoundaryReached 报告**保留期边界已到达**:该会话**确有更早的轮次已被
// 保留期清扫删掉**,且本次已取到它现存最早的一端(切片 X1 新增;契约版本待主会话
// 登记 —— 本字段需一次版本号,建议 v0.81)。
//
// 判据(三条一起成立,逐条可复核):① 保留期清扫已启用(`retentionEnabled`);
// ② `hasMore:false`(本次不是半页 —— 只看半页就说「已过保留期」正是修掉的那句假话);
// ③ `flowgraph.EarlierRoundsSwept`:该会话现存最早一轮的轮次号 > 1(轮次号自 1 起、
// 每会话跨重启单调 ⇒ 号更小的轮次曾落盘,而 `flow_graphs` 的唯一删除者就是保留期
// 清扫)。⇒ **客户端只有看到 `true` 才可以说「更早的轮次已过保留期」**,其余情形一律
// 说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」(`source:"live"`)。
//
// 诚实的盲区(写在契约里,不掩盖):一个会话若**整段历史都被清扫**(一条可用行都不
// 剩),发号从 1 重新开始 ⇒ 本字段为 false(**少说**,绝不多说);有轮次号未知的老行
// 在场时同样为 false。
RetentionBoundaryReached bool `json:"retentionBoundaryReached"`
// RetentionHours 是落盘保留期(小时,下取整);`RetentionEnabled` 为 false 时保留期
// 清扫**未启用** ⇒ "更早的轮次" 不是被清扫掉的,别把它说成过期。
// `FLOW_GRAPH_RETENTION` 是既有旋钮(默认 168h),本片**不新增任何配置键**。
@@ -169,17 +191,34 @@ func (s *Server) adminFlowTimeline(w http.ResponseWriter, r *http.Request) {
hasMore = true
}
writeJSON(w, http.StatusOK, adminFlowTimelineResponse{
SessionID: sessionID,
Source: timelineSource(persisted, len(merged) > 0),
Rounds: flowTimelineRoundsOf(merged),
NextBefore: timelineNextBefore(merged),
HasMore: hasMore,
Truncated: hasMore || !storeWired,
RetentionHours: int(retention / time.Hour),
RetentionEnabled: retention > 0,
SessionID: sessionID,
Source: timelineSource(persisted, len(merged) > 0),
Rounds: flowTimelineRoundsOf(merged),
NextBefore: timelineNextBefore(merged),
HasMore: hasMore,
Truncated: hasMore || !storeWired,
// 保留期边界:只有「清扫已启用 + 已到最早一端 + 确有更早的轮次被清扫删掉」三条
// 同时成立才为 true(判据见 DTO 注释与 design/19 §7.1)。
RetentionBoundaryReached: retainedEarlierRoundsSwept(merged, hasMore, retention),
RetentionHours: int(retention / time.Hour),
RetentionEnabled: retention > 0,
})
}
// retainedEarlierRoundsSwept 是 `retentionBoundaryReached` 的判据本体:清扫已启用
// (retention > 0)、本次已到该会话现存最早的一端(!hasMore)、且该会话**确有**更早的
// 轮次已被清扫删掉(`flowgraph.EarlierRoundsSwept` 的轮次号下界判据)。
//
// 三条缺一不可:少了第一条会说「保留期」却根本没开清扫;少了第二条会对着**仍然可翻**的
// 历史说「已过保留期」(切片 X1 修掉的那句假话);少了第三条会把一个刚开张、什么都没被
// 清掉的会话说成「更早的轮次已过保留期」。
func retainedEarlierRoundsSwept(merged []flowgraph.TimelineRound, hasMore bool, retention time.Duration) bool {
if retention <= 0 || hasMore {
return false
}
return flowgraph.EarlierRoundsSwept(merged)
}
// timelineLimitParam 解析 `?limit=`:空串 = 未给(0 ⇒ 存储层用缺省 `DefaultTurnsLimit`);
// 非法整数报错(400,绝不静默当缺省);负值归一成 0;超过硬上限 `MaxTurnsLimit` 即钳住。
func timelineLimitParam(raw string) (int, error) {