fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。

【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
  ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
  队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
  graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
  子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
  只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
  单路/未给 lanes 时渲染路径与改前逐字相同

【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)

【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)

⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
This commit is contained in:
AskaEth committed 2026-10-06 08:16:03 +08:00
1 parent c9144c1096
commit 89434eadef
30 files changed
+2039 -148

No files matched your search

+16 -4
View File
@@ -22,7 +22,7 @@ import {
mergeMessage,
patchAssistantTail,
patchMessage,
putGraph,
writeFlowGraph,
replacePage,
withBucket,
writeSession,
@@ -75,6 +75,12 @@ export interface AppStore extends TurnSlice {
* conversation the admin panel is looking at. Bounded by `MAX_GRAPHS`
* (store/sessionCache.ts), most recently updated first. */
flowGraphs: Record<string, FlowGraph>;
/** Per-session **lane** graphs (切片 FV2): every 路 (leg) of the session's latest
* round, oldest first, keyed by the graph's own `turnId`. Real parallel turns
* publish one graph per leg; they used to overwrite each other in the single
* session bucket. `flowGraphs` keeps its «session → newest graph» meaning (compat),
* the Flow page reads this list to draw each leg of the round as its own lane. */
flowLanes: Record<string, FlowGraph[]>;
/** Compatibility alias for the admin Flow / Agents pages: the graph of the
* *active* session, falling back to the main session's latest one. Kept in sync
* by `setFlowGraph` / `setActiveSession` / `resetChat`. */
@@ -212,6 +218,7 @@ export const useAppStore = create<AppStore>()(
],
backthink: [],
flowGraphs: {},
flowLanes: {},
flowGraph: null,
deviceUpdates: [],
awakenState: "quiet",
@@ -426,10 +433,14 @@ export const useAppStore = create<AppStore>()(
setFlowGraph: (graph, sessionId) =>
set((s) => {
// 归属会话:图自己带的 sessionId(后端 RunGraph 建图时写入)优先,其次帧壳的,
// 最后落到当前会话。每张图只进它自己的桶,互不覆盖(裁决 16.4.2)。
// 最后落到当前会话。每张图只进它自己的桶,互不覆盖(裁决 16.4.2);真并行时
// 同一条会话的每一路再各占一格(切片 FV2,见 sessionCache.writeFlowGraph)。
const sid = graph.sessionId ?? sessionId ?? s.activeSessionId;
const flowGraphs = putGraph(s.flowGraphs, sid, graph);
return { flowGraphs, flowGraph: currentGraph(flowGraphs, s.activeSessionId) };
const next = writeFlowGraph(s.flowGraphs, s.flowLanes, sid, graph);
return {
...next,
flowGraph: currentGraph(next.flowGraphs, s.activeSessionId),
};
}),
addDeviceUpdate: (text) =>
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
@@ -468,6 +479,7 @@ export const useAppStore = create<AppStore>()(
sessionMessages: {},
moods: {},
flowGraphs: {},
flowLanes: {},
flowGraph: null,
backthink: s.backthink,
awakenState: "quiet",