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
@@ -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