fix(flow): graph_update 按路分桶——修真并行两路的图互相覆盖(FV2)
【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 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:
1 parent
c9144c1096
commit
89434eadef
30 files changed
+2039
-148
No files matched your search
@@ -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);
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user