Files
Cyrene_v2/frontend/web/src/store/flowLanes.test.ts
T
AskaEth 89434eadef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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) 提交里(同一个工作时段内)。
2026-10-06 08:16:03 +08:00

167 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`);