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

+47 -18
View File
@@ -7,6 +7,7 @@ import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
import { NodeDetail } from "./flow/NodeDetail";
import { LaneSummary } from "./flow/LaneSummary";
import { LaneTracks } from "./flow/LaneTracks";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { useFlowTurns } from "./flow/useFlowTurns";
@@ -38,10 +39,16 @@ import type { AdminFlowTurn } from "../../lib/api";
export function FlowModule(): React.ReactNode {
const sessions = useAppStore((s) => s.sessions);
const flowGraphs = useAppStore((s) => s.flowGraphs);
const flowLanes = useAppStore((s) => s.flowLanes);
// 选择器选中值:默认主会话 `web`(后端固定 MainSessionID),不随激活会话漂移。
const [pickedSessionId, setPickedSessionId] = useState(MAIN_SESSION_ID);
// 取数接线(纯函数,flowSession.ts):用哪个 sessionId 取数 / 画哪张图 / 有哪些选项。
const { sessionId, graph, options } = flowPageData(sessions, flowGraphs, pickedSessionId);
// 取数接线(纯函数,flowSession.ts):用哪个 sessionId 取数 / 画哪张图 / 本轮有哪些路 / 选项。
const { sessionId, graph, lanes, options } = flowPageData(
sessions,
flowGraphs,
pickedSessionId,
flowLanes,
);
// 最新一轮:F1 的实时图 + 只读快照补拉(补拉会写进图桶 ⇒ 配合图桶持久化「刷新即刻有图」)。
const graphSource = useFlowSnapshot(sessionId);
// 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**,
@@ -50,6 +57,7 @@ export function FlowModule(): React.ReactNode {
return (
<FlowBody
graph={graph}
lanes={lanes}
sessionId={sessionId}
source={graphSource}
sessionOptions={options}
@@ -69,9 +77,14 @@ export function FlowModule(): React.ReactNode {
* **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画
* 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。
* 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。
* 同理,会话选择器只在给了 `onSelectSession` 时渲染。 */
* 同理,会话选择器只在给了 `onSelectSession` 时渲染。
*
* **多路(切片 FV2)**:`lanes` 是选中会话本轮**每一路**的图。≥2 路时每条路各占一条
* 泳道卡(并行路各自成图、互不覆盖);单路 / 未给 `lanes` 时渲染路径与引入本键之前
* 逐字相同(既有渲染断言因此不受影响)。 */
export function FlowBody({
graph,
lanes,
sessionId,
source,
sessionOptions,
@@ -83,6 +96,8 @@ export function FlowBody({
historyLoading = false,
}: {
graph: FlowGraph | null;
/** 本轮每一路的图(最旧的在前);省略时按单路处理(只用 `graph`)。 */
lanes?: readonly FlowGraph[];
sessionId: string;
source: FlowGraphSource;
/** 会话选择器选项(只含白名单内会话)。 */
@@ -110,6 +125,12 @@ export function FlowBody({
// 本回合并行子代理泳道数。0 时统计卡**不显示 `0`**:那张卡的标签是「泳道」,`0` 紧挨着
// 标签会被读成「泳道 0」(主人报的正是这个),而事实是「本回合没有派发子代理」。
const laneCount = grouping?.lanes.length ?? 0;
// 多路:只看实时/快照那一支(历史轮是**某一轮**的落盘图,不参与「本轮多路」)。两条
// 以上的路时每条路各画一张,卡片标题带该路的权威 turnId —— 真并行下这就是主人在
// 面板上要看到的「两路各自的图」,而不是被后起的那一路顶掉的一张。
const laneList = lanes ?? [];
const parallelLanes: readonly FlowGraph[] =
!showingHistory && laneList.length > 1 ? laneList : [];
return (
<div className="space-y-5">
@@ -172,24 +193,32 @@ export function FlowBody({
/>
</CardGrid>
<Surface className="space-y-3">
<StatusStrip graph={shown} />
<FlowGraphView
graph={shown}
orientation="newest-top"
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
{parallelLanes.length > 0 ? (
<LaneTracks
lanes={parallelLanes}
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
{showingHistory ? null : (
<FlowSnapshotHint
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
) : (
<Surface className="space-y-3">
<StatusStrip graph={shown} />
<FlowGraphView
graph={shown}
orientation="newest-top"
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
/>
)}
</Surface>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
{showingHistory ? null : (
<FlowSnapshotHint
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
)}
</Surface>
)}
{selected ? (
{selected && parallelLanes.length === 0 ? (
<NodeDetail
node={selected}
laneLabel={selectedLane}
@@ -0,0 +1,85 @@
import { useState } from "react";
import type { FlowGraph } from "../../../lib/types";
import { Surface } from "../../../components/primitives";
import { FlowGraphView } from "../FlowGraph";
import { NodeDetail } from "./NodeDetail";
import { LaneSummary } from "./LaneSummary";
import { FlowSnapshotHint } from "./FlowSnapshotHint";
import type { FlowGraphSource } from "./useFlowSnapshot";
import { groupFlowLanes } from "./lanes";
/** 真并行(`chat.parallel`)的**多路泳道**呈现(切片 FV2;design/19 §2、design/15 §2.5.10-1)。
*
* 背景:同一会话真并行时两路各自开一轮、各有各的图,而后端只按会话保留「最近一张」
* (兼容字段)⇒ 后起的那一路会把前一路从面板上顶掉,看上去就是「一路的节点长在另一路
* 的图上」。后端现在按**路**分桶并各发一帧(各带自己的 `turnId`),本组件把每一路画成
* 一条独立泳道,于是两路在页面上并列可见、互不覆盖。
*
* 单路时本组件根本不会渲染(`FlowBody` 只在 ≥2 路时用它),因此既有单图呈现逐字不变。
* 每条泳道自带节点详情(点节点看参数 / 结果 / 实时日志),路与路之间不共享选中态 ——
* 两路的节点 id 本来就重名(都有 `input`/`output`),共用一个选中值必然串台。
*/
export function LaneTracks({
lanes,
sessionId,
source,
}: {
/** 本轮每一路的图(最旧的在前)。 */
lanes: readonly FlowGraph[];
/** 归属会话(拼快照说明条用)。 */
sessionId: string;
/** 图的来源(实时 / 快照),用于说明条。 */
source: FlowGraphSource;
}): React.ReactNode {
return (
<div className="space-y-4">
{lanes.map((lane, i) => (
<LaneTrack key={lane.id} lane={lane} index={i + 1} sessionId={sessionId} source={source} />
))}
</div>
);
}
/** 一条路的泳道卡:标题(第几路 + 权威 turnId)、图、子代理泳道摘要、节点详情。 */
function LaneTrack({
lane,
index,
sessionId,
source,
}: {
lane: FlowGraph;
/** 展示用的路序号(1 基,最旧的为 1)。 */
index: number;
sessionId: string;
source: FlowGraphSource;
}): React.ReactNode {
const [selectedId, setSelectedId] = useState<string | null>(null);
const grouping = groupFlowLanes(lane);
const selected = lane.nodes.find((n) => n.id === selectedId) ?? null;
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2 px-1 text-xs">
<span className="rounded-full bg-[var(--mu-surface-2)] px-2 py-0.5 font-medium text-[var(--mu-text-hi)]">
路 {index}
</span>
<span className="text-[var(--mu-text-lo)]">
{lane.turnId ? `turnId ${lane.turnId}` : "无路标识(旧后端 / 单路形状)"}
</span>
<span className="text-[var(--mu-text-lo)]">
{lane.nodes.length} 节点 · {lane.edges.length} 连线
</span>
</div>
<FlowGraphView
graph={lane}
orientation="newest-top"
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping.lanes} onSelect={setSelectedId} />
<FlowSnapshotHint sessionId={lane.sessionId ?? sessionId} source={source ?? "live"} />
{selected ? (
<NodeDetail node={selected} laneLabel={undefined} onClose={() => setSelectedId(null)} />
) : null}
</Surface>
);
}
@@ -28,12 +28,16 @@ export interface FlowSessionOption {
label: string;
}
/** Flow 页的取数接线:`sessionId` 给取数层(hook / REST)用,`graph` 是屏幕上要画的图。 */
/** Flow 页的取数接线:`sessionId` 给取数层(hook / REST)用,`graph` 是屏幕上要画的图,
* `lanes` 是该会话**本轮每一路**的图(真并行时不止一张,见切片 FV2)。 */
export interface FlowPageData {
/** 取数用的会话 id(`useFlowSnapshot` / `useFlowTurns` / REST 的 `sessionId`)。 */
sessionId: string;
/** 该会话的图(取自**它自己的**图桶;没有就是没有,绝不编图)。 */
graph: FlowGraph | null;
/** 该会话本轮每一路的图(最旧的在前)。路桶还没有内容时(单路 / 旧后端 / 快照补拉
* 前)回落到 `graph` 那一条 —— 单路下「每一路」就是那一路。 */
lanes: FlowGraph[];
/** 会话选择器选项(只含白名单内会话)。 */
options: FlowSessionOption[];
}
@@ -96,17 +100,23 @@ export function resolveFlowSessionId(
return options.some((o) => o.id === picked) ? picked : MAIN_SESSION_ID;
}
/** Flow 页的取数接线(纯函数):会话列表 + 图桶 + 选择器选中值 ⇒
* 「用哪个 sessionId 取数 / 画哪张图 / 有哪些选项」。
/** Flow 页的取数接线(纯函数):会话列表 + 图桶 + 路桶 + 选择器选中值 ⇒
* 「用哪个 sessionId 取数 / 画哪张图 / 本轮有哪些路 / 有哪些选项」。
*
* `graph` **只看选中会话自己的图桶**,绝不回落到兼容字段 `flowGraph`(它按
* `activeSessionId` 取 ⇒ 切了会话图也不变,本切片要修的就是这个洞)。 */
* `activeSessionId` 取 ⇒ 切了会话图也不变,本切片要修的就是这个洞)。
*
* `lanes` 是该会话**本轮每一路**的图(真并行时两条以上);路桶还没有内容时回落到
* 单个 `graph` —— 单路/旧后端下「每一路」就是那一路,绝不让页面因为路桶为空而空掉。 */
export function flowPageData(
sessions: Record<string, SessionMeta>,
flowGraphs: Record<string, FlowGraph>,
picked: string,
flowLanes?: Record<string, FlowGraph[]>,
): FlowPageData {
const options = flowSessionOptions(sessions);
const sessionId = resolveFlowSessionId(picked, options);
return { sessionId, graph: flowGraphs[sessionId] ?? null, options };
const graph = flowGraphs[sessionId] ?? null;
const lanes = flowLanes?.[sessionId]?.length ? flowLanes[sessionId] : graph ? [graph] : [];
return { sessionId, graph, lanes, options };
}
@@ -0,0 +1,112 @@
// 「真并行两路」的**真实渲染**测试(切片 FV2;design/19 §2、design/15 §2.5.10-1)。
//
// 为什么需要它:逻辑测试(store/flowLanes.test.ts)钉住「两路进了两个桶」,而主人报的
// 症状是**看得见**的 —— 面板上只出现一路的图(后起的那一路把前一路顶掉)。这里钉住
// 「桶里有两路 ⇒ 页面上真的并列画出两条泳道」,并且单路时渲染路径与引入本键之前一致
// (既有渲染测试因此不受影响)。
//
// 渲染的是 **props 驱动**的 `FlowBody`:本项目没有 jsdom,store 驱动的组件在测试里只能
// 看到初始 state ⇒ 只能喂 props。
//
// 跑法(渲染类测试必须 `--format=cjs`,见 messageSync.test.ts 顶部注释):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/laneTracks.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-flow-lanes-render.test.cjs \
// && node /tmp/cyrene-flow-lanes-render.test.cjs
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { FlowGraph } from "../../../lib/types";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import { FlowBody } from "../Flow";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
const has = (haystack: string, needle: string): boolean => haystack.includes(needle);
/** 一路的图:输入 + 一个以该路命名的工具节点(label 是断言用的哨兵)。 */
function laneGraph(id: string, turnId: string, sentinel: string): FlowGraph {
return {
id,
sessionId: MAIN_SESSION_ID,
turnId,
round: 1,
nodes: [
{ id: "input", label: `${sentinel}输入`, kind: "input", status: "done" },
{ id: `span-${turnId}`, label: sentinel, kind: "tool", status: "done" },
{ id: "output", label: `${sentinel}回复`, kind: "output", status: "done" },
],
edges: [
{ from: "input", to: `span-${turnId}` },
{ from: `span-${turnId}`, to: "output" },
],
};
}
const laneA = laneGraph("web-1#turn-a", "turn-a", "哨兵A");
const laneB = laneGraph("web-2#turn-b", "turn-b", "哨兵B");
// ── ① 两路 ⇒ 两条泳道(两路的哨兵都在,且各有各的 turnId 标题) ─────────────
const twoLanes = renderToStaticMarkup(
createElement(FlowBody, {
graph: laneB,
lanes: [laneA, laneB],
sessionId: MAIN_SESSION_ID,
source: "live",
})
);
ok(has(twoLanes, "路 1") && has(twoLanes, "路 2"), "两路时画出两条泳道卡");
ok(has(twoLanes, "turn-a") && has(twoLanes, "turn-b"), "每条泳道标出自己那一路的 turnId");
ok(
has(twoLanes, "哨兵A") && has(twoLanes, "哨兵B"),
"两路的图都在页面上(后起的那一路绝不顶掉前一路)",
);
ok(
twoLanes.split("3 节点").length === 3,
"两条泳道各自带自己的节点统计(两张图各画一次,不是同一张画两遍)",
);
// ── ② 单路 / 未给 lanes ⇒ 与引入本键之前的单图呈现一致(没有泳道标题) ──────
const single = renderToStaticMarkup(
createElement(FlowBody, {
graph: laneB,
lanes: [laneB],
sessionId: MAIN_SESSION_ID,
source: "live",
})
);
ok(has(single, "哨兵B"), "单路仍画出那一张图");
ok(!has(single, "路 1"), "单路不出现「路 N」泳道标题(保持既有呈现)");
const withoutLanes = renderToStaticMarkup(
createElement(FlowBody, {
graph: laneB,
sessionId: MAIN_SESSION_ID,
source: "live",
})
);
ok(!has(withoutLanes, "路 1"), "省略 lanes 时按单路渲染(向后兼容)");
// ── ③ 历史轮:翻历史轮时走那一轮的落盘图,不按「本轮多路」铺开 ──────────────
const history = renderToStaticMarkup(
createElement(FlowBody, {
graph: laneB,
lanes: [laneA, laneB],
sessionId: MAIN_SESSION_ID,
source: "live",
selectedTurnId: "web-1#turn-a",
historyGraph: laneA,
onSelectTurn: () => {},
})
);
ok(has(history, "哨兵A"), "翻历史轮画的是选中的那一轮");
ok(!has(history, "路 1"), "历史轮不走「本轮多路」的泳道铺开");
console.log(`flow lanes render test: ${checks} checks passed`);
@@ -48,6 +48,11 @@ export function useFlowSnapshot(sessionId: string): FlowGraphSource {
adminFlowGraph(sessionId)
.then((resp) => {
if (cancelled || !resp.graph) return;
// 真并行时同一轮有多条路(契约 v0.77 的 `lanes[]`):每一路都写进图桶,
// 于是冷启动的页面同样看到「两路各自的图」,而不是只剩最新那一路。
for (const lane of resp.lanes ?? []) {
if (lane.graph) setFlowGraph(lane.graph, sessionId);
}
setFlowGraph(resp.graph, sessionId);
setPulled(resp.graph);
})