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);
|
||||
})
|
||||
|
||||
@@ -68,9 +68,20 @@ export interface AdminFlowGraphResponse {
|
||||
turnId: string;
|
||||
source: "live" | "persisted" | "none";
|
||||
graph: FlowGraph | null;
|
||||
/** 最近一轮**每一路**的图(契约 v0.77,纯增量;真并行 `chat.parallel` 时不止一条)。
|
||||
* `graph` 只是其中最新的一条(向后兼容),`lanes` 才是全貌 —— 面板据此把两路并列
|
||||
* 画出来,而不是让后起的那一路把前一路顶掉。没有图时是 `[]`(后端保证非 null)。 */
|
||||
lanes: AdminFlowLane[];
|
||||
turns: AdminFlowTurn[];
|
||||
}
|
||||
|
||||
/** `lanes[]` 里的一条路:`turnId` 是该路的权威回合标识(design/15 §2.5.9),
|
||||
* `graph` 与响应的 `graph` / 帧的 data 逐字同形。 */
|
||||
export interface AdminFlowLane {
|
||||
turnId: string;
|
||||
graph: FlowGraph | null;
|
||||
}
|
||||
|
||||
/** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约
|
||||
* v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。
|
||||
*
|
||||
|
||||
@@ -23,6 +23,17 @@ export interface FlowGraph {
|
||||
edges: FlowEdge[];
|
||||
/** The graph's owning session id (backend `sessionId`). */
|
||||
sessionId?: string;
|
||||
/** The **lane** this graph belongs to: the authoritative turn id of the leg that
|
||||
* produced it (backend `turnId`, design/15 §2.5.9). Absent when that leg has no
|
||||
* authoritative id (single leg / older backend) — then the client keeps the
|
||||
* per-session bucket only (compat), never inventing an id. Real parallel turns
|
||||
* (`chat.parallel`) publish one graph per lane, and this key is what keeps them
|
||||
* apart (design/15 §2.5.10-1). */
|
||||
turnId?: string;
|
||||
/** The session round number this graph belongs to (backend `round`, monotonic per
|
||||
* session; every lane of one round shares it). The store uses it to drop the
|
||||
* lanes of earlier rounds. Absent (older backend) keeps every lane it received. */
|
||||
round?: number;
|
||||
/** Round start/end times (RFC3339), when the backend provides them. */
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
|
||||
@@ -172,6 +172,10 @@ export function parseGraph(payload: unknown): FlowGraph | null {
|
||||
sessionId:
|
||||
(typeof r.sessionId === "string" ? r.sessionId : undefined) ??
|
||||
(typeof graphRec.sessionId === "string" ? graphRec.sessionId : undefined),
|
||||
// 路标识与轮次号(契约 v0.77,纯增量):`turnId` 缺席表示这一路没有权威标识
|
||||
// (单路 / 旧后端)⇒ 客户端只按会话归一张图;`round` 缺席按「未知轮次」处理。
|
||||
turnId: typeof graphRec.turnId === "string" ? graphRec.turnId : undefined,
|
||||
round: typeof graphRec.round === "number" ? graphRec.round : undefined,
|
||||
startedAt: typeof graphRec.startedAt === "string" ? graphRec.startedAt : undefined,
|
||||
endedAt: typeof graphRec.endedAt === "string" ? graphRec.endedAt : undefined,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Logic test for the **per-lane** flow buckets (切片 FV2): a real parallel turn
|
||||
// (`chat.parallel`) publishes one graph per 路 (leg), each carrying its own
|
||||
// `turnId`; the store must hold them as separate swimlanes instead of letting the
|
||||
// later frame overwrite the earlier one (the reported symptom: 「一路的节点长在另
|
||||
// 一路的图上」, design/15 §2.5.10-1).
|
||||
//
|
||||
// It drives the real frame decoder (`dispatchFrame` in lib/ws.ts) and the real
|
||||
// store, and throws on the first failure. The project has no frontend test runner
|
||||
// and no jsdom, so this is a dependency-free plain-node check; the banner supplies
|
||||
// the `window.localStorage` the store's persist middleware probes (absent under node):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/store/flowLanes.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-flow-lanes.test.mjs \
|
||||
// && node /tmp/cyrene-flow-lanes.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { useAppStore } from "./useAppStore";
|
||||
import { MAIN_SESSION_ID, MAX_LANES } from "./sessionCache";
|
||||
import { flowPageData } from "../admin/modules/flow/flowSession";
|
||||
import type { FlowGraph, SessionMeta } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
/** 一帧 `graph_update`(真后端发的形状:图自带 sessionId / turnId / round)。 */
|
||||
function laneFrame(id: string, turnId: string | undefined, round: number | undefined, label: string): void {
|
||||
dispatchFrame({
|
||||
type: "graph_update",
|
||||
data: {
|
||||
id,
|
||||
sessionId: MAIN_SESSION_ID,
|
||||
...(turnId === undefined ? {} : { turnId }),
|
||||
...(round === undefined ? {} : { round }),
|
||||
nodes: [{ id: "input", label, kind: "input", status: "done" }],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 当前会话的路桶(最旧的在前)。 */
|
||||
function lanesOf(sessionId: string): FlowGraph[] {
|
||||
return useAppStore.getState().flowLanes[sessionId] ?? [];
|
||||
}
|
||||
|
||||
// ① 两路帧**交错**到达 ⇒ 两条泳道,而不是一条(这是本切片的核心断言)。
|
||||
{
|
||||
laneFrame("web-1#turn-a", "turn-a", 1, "标记A");
|
||||
laneFrame("web-2#turn-b", "turn-b", 1, "标记B");
|
||||
// 两路各自继续推进(同一个 turnId 再发一帧):只能就地更新,绝不能新开一格。
|
||||
laneFrame("web-1#turn-a", "turn-a", 1, "标记A(更新)");
|
||||
laneFrame("web-2#turn-b", "turn-b", 1, "标记B(更新)");
|
||||
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).map((g) => g.turnId),
|
||||
["turn-a", "turn-b"],
|
||||
"两条路各占一格(交错到达也绝不互相覆盖)",
|
||||
);
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).map((g) => g.nodes[0]?.label),
|
||||
["标记A(更新)", "标记B(更新)"],
|
||||
"同一条路的后续帧就地更新自己那一格",
|
||||
);
|
||||
same(
|
||||
useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id,
|
||||
"web-2#turn-b",
|
||||
"兼容字段(会话桶)仍是最近那一张 —— 老读法不受影响",
|
||||
);
|
||||
}
|
||||
|
||||
// ② 新一轮(round 更大)到达 ⇒ 上一轮的路退场;单路(无 turnId)也只占一格。
|
||||
{
|
||||
laneFrame("web-5", undefined, 2, "单路的下一轮");
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).map((g) => g.turnId ?? ""),
|
||||
[""],
|
||||
"新一轮开始后只留本轮的路(上一轮的两条路已退场)",
|
||||
);
|
||||
laneFrame("web-9", undefined, undefined, "旧后端的帧(无 round)");
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).map((g) => g.id),
|
||||
["web-9"],
|
||||
"无轮次号 / 无路标识的旧帧按空路键就地更新,绝不多开一格",
|
||||
);
|
||||
}
|
||||
|
||||
// ③ 路数有硬上限:绝不随帧无界增长。
|
||||
{
|
||||
for (let i = 0; i < MAX_LANES + 3; i += 1) {
|
||||
laneFrame(`web-x${i}`, `turn-x${i}`, 3, `路${i}`);
|
||||
}
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).length,
|
||||
MAX_LANES,
|
||||
"路桶有硬上限(不随并行路数无界增长)",
|
||||
);
|
||||
same(
|
||||
lanesOf(MAIN_SESSION_ID).map((g) => g.turnId),
|
||||
["turn-x3", "turn-x4", "turn-x5", "turn-x6"],
|
||||
"超限时丢掉最旧的路",
|
||||
);
|
||||
}
|
||||
|
||||
// ④ Flow 页取数:≥2 路时 `flowPageData` 交出每一路;只有单张图时回落到那一条。
|
||||
{
|
||||
const sessions: Record<string, SessionMeta> = {};
|
||||
const page = flowPageData(
|
||||
sessions,
|
||||
useAppStore.getState().flowGraphs,
|
||||
MAIN_SESSION_ID,
|
||||
useAppStore.getState().flowLanes,
|
||||
);
|
||||
same(page.sessionId, MAIN_SESSION_ID, "取数会话 = 选中会话");
|
||||
same(page.lanes.length, MAX_LANES, "页面拿到本轮每一路(供并列泳道渲染)");
|
||||
|
||||
const single: Record<string, FlowGraph> = {
|
||||
[MAIN_SESSION_ID]: { id: "web-1", nodes: [], edges: [], sessionId: MAIN_SESSION_ID },
|
||||
};
|
||||
const fallback = flowPageData(sessions, single, MAIN_SESSION_ID, {});
|
||||
same(
|
||||
fallback.lanes.map((g) => g.id),
|
||||
["web-1"],
|
||||
"路桶为空时回落为那一张图(单路 / 旧后端 / 快照补拉前)",
|
||||
);
|
||||
}
|
||||
|
||||
// ⑤ 会话隔离:另一会话的路桶不会被本会话的帧写脏(裁决 16.4.2 的口径不变)。
|
||||
{
|
||||
dispatchFrame({
|
||||
type: "graph_update",
|
||||
data: {
|
||||
id: "sess-backthink-1",
|
||||
sessionId: "sess-backthink",
|
||||
turnId: "turn-bt",
|
||||
round: 1,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
same(
|
||||
lanesOf("sess-backthink").map((g) => g.turnId),
|
||||
["turn-bt"],
|
||||
"别的会话的路进它自己的桶",
|
||||
);
|
||||
ok(
|
||||
lanesOf(MAIN_SESSION_ID).every((g) => g.turnId !== "turn-bt"),
|
||||
"别的会话的路绝不混进本会话",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`flow lanes logic test: ${checks} checks passed`);
|
||||
@@ -11,7 +11,9 @@ import type { AppStore } from "./useAppStore";
|
||||
* 桶、把切到的会话取出,于是「切会话不再清空」。
|
||||
* ② **链路图桶**(裁决 16.4.2):一个会话最近一张 `graph_update` 快照。管理后台按
|
||||
* `sessionId` 取图,而不是所有页面共用一张全局图——后台思考的完整链路图与
|
||||
* 「用户侧前端发不发」无关,也绝不顶掉管理员看的「当前会话」图。
|
||||
* 「用户侧前端发不发」无关,也绝不顶掉管理员看的「当前会话」图。真并行时同一
|
||||
* 会话同一轮有**多条路**(切片 FV2),于是会话桶之外还有**路桶**:同一个会话的
|
||||
* 每条路各占一格(`putLane`),两路交错的帧因此不会互相覆盖。
|
||||
*
|
||||
* 从 `useAppStore.ts` 拆出来(AGENTS §10 文件 ≤500 行 / 三次复制必须抽取):下面每条
|
||||
* 规则都被「激活会话」与「非激活会话」两条写入路径共用,放在一处才不会走样。
|
||||
@@ -235,6 +237,51 @@ export function putGraph(
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 一条**路**在 store 里的键(切片 FV2):图自己的 `turnId`;没有权威标识时用空串
|
||||
* —— 那是「会话那一张」(单路 / 旧后端),因此旧形状的帧仍然只占一个桶。 */
|
||||
export function laneKeyOf(graph: FlowGraph): string {
|
||||
return graph.turnId ?? "";
|
||||
}
|
||||
|
||||
/** 一个会话最多同时保留多少条路的图。真并行的硬上限是 3 路(后端
|
||||
* `maxParallelTurnsHardCap`),留一格余量,绝不无界增长。导出它是因为前端逻辑测试
|
||||
* 必须能钉住这条上限,而不是在测试里抄一个字面量。 */
|
||||
export const MAX_LANES = 4;
|
||||
|
||||
/** 把一帧图并进该会话**本轮**的路列表:按路键就地更新(同一条路的高频帧只占一格),
|
||||
* 并丢掉**更早轮次**的路 —— 轮次号更大的帧到达即表示新一轮开始,上一轮的路该退场
|
||||
* (缺席轮次号的旧帧不参与淘汰,按「保留」处理,向后兼容)。
|
||||
*
|
||||
* 这是「两路互不覆盖」的前端半边:后端给每条路各发一帧(各带自己的 `turnId`),
|
||||
* 这里按路分格存放,于是两路交错到达时 store 里是两条泳道而不是一条。 */
|
||||
export function putLane(
|
||||
lanes: readonly FlowGraph[] | undefined,
|
||||
graph: FlowGraph
|
||||
): FlowGraph[] {
|
||||
const key = laneKeyOf(graph);
|
||||
const list = [...(lanes ?? [])];
|
||||
const at = list.findIndex((g) => laneKeyOf(g) === key);
|
||||
if (at >= 0) list[at] = graph;
|
||||
else list.push(graph);
|
||||
const round = graph.round;
|
||||
const kept =
|
||||
round === undefined ? list : list.filter((g) => g.round === undefined || g.round >= round);
|
||||
return kept.slice(-MAX_LANES);
|
||||
}
|
||||
|
||||
/** 一次图写入的完整结果:会话桶(向后兼容的「该会话最新一张」)+ 路桶(本轮每一路)。 */
|
||||
export function writeFlowGraph(
|
||||
graphs: Record<string, FlowGraph>,
|
||||
lanes: Record<string, FlowGraph[]>,
|
||||
sessionId: string,
|
||||
graph: FlowGraph
|
||||
): { flowGraphs: Record<string, FlowGraph>; flowLanes: Record<string, FlowGraph[]> } {
|
||||
return {
|
||||
flowGraphs: putGraph(graphs, sessionId, graph),
|
||||
flowLanes: { ...lanes, [sessionId]: putLane(lanes[sessionId], graph) },
|
||||
};
|
||||
}
|
||||
|
||||
/** 兼容字段 `flowGraph` 的口径(管理端 Flow / Agents 页读它):**激活会话**的图;
|
||||
* 激活会话还没有图时回落主会话的最近一张,使页面不至于空着。
|
||||
* 后台思考会话的图不会顶掉它 —— 这正是裁决 16.4.2 要修的:每条思考看自己的图,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user