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",
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
// (`cmd/cyrene` 的 `flowSessionAllowed`,design/19 §1 裁决 01.4.5)决定 ——
|
||||
// 本包只被**已经过白名单的** `EndRound` 调用,因此不再存一份白名单。
|
||||
// - **写入时机 = 每轮一条**(`EndRound`),**不是每 span 一条**:span 级写放大是纯浪费,
|
||||
// 图在收尾时已完整(`output` 节点已并入)。
|
||||
// 图在收尾时已完整(`output` 节点已并入)。真并行时一轮有多条路(design/15 §2.5.5),
|
||||
// 因此实际是**每一路一条** —— 两路各有各的 `turn_id`,唯一键 `(session_id, turn_id)`
|
||||
// 因此不会把其中一路顶上(FV2;design/15 §2.5.10-1)。
|
||||
// - **不是图的真源**:实时图仍由发布器持有(`graph_update` 帧的唯一来源);本包只是
|
||||
// 一份**收尾存档**,供「打开页面后翻历史轮」。读写失败都**不许**影响会话收尾。
|
||||
//
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package flowgraph
|
||||
|
||||
// 本文件钉住**真并行两路的落盘不撞 key**(design/15 §2.5.10-1 + design/19 §4 P3;
|
||||
// 切片 FV2)。症状:两路在同一会话各收一轮,若两路的 `turn_id` 相同,
|
||||
// `(session_id, turn_id)` 唯一键会让两路 upsert 成**一行** —— 后收尾的那一路把前一路
|
||||
// 整张图顶掉,翻历史轮时那一路的图彻底消失。
|
||||
//
|
||||
// 断言的是**库里的事实**(几行、每行 id 是什么、读回来的图属于哪一路),不按日志断言,
|
||||
// 与 flowgraph_test.go 同一口径。夹具用 `trace.RunGraph` 走真建图路径(两路各自
|
||||
// SetTurnID + SetSeq),因此库里的 turn_id 与线上图 id 逐字同源。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// laneGraph 用真建图器造一条路的收尾图:seq 是会话级建图序号(两路必不同),
|
||||
// lane 是这一路的权威 turnId。
|
||||
func laneGraph(session string, seq int, lane, input, output string) *trace.FlowGraph {
|
||||
g := trace.NewRunGraph(session)
|
||||
g.SetTurnID(lane)
|
||||
g.SetSeq(seq)
|
||||
g.Start(seq, testNow().Add(-time.Minute))
|
||||
g.SeedInput(input)
|
||||
g.Upsert(trace.NodeSpec{ID: "span-" + lane, Label: lane, Kind: "tool", Status: trace.FlowStatusDone, ParentID: "input"})
|
||||
g.SeedOutput(output)
|
||||
return g.Snapshot()
|
||||
}
|
||||
|
||||
// TestStore_ParallelLanesPersistSeparateRows_正常 是本文件的核心断言:同一会话同一轮
|
||||
// 的两路各占**一行**,按各自的路标识查得回来,`Turns` 列出两条,任一路都没有被
|
||||
// 另一路顶掉。
|
||||
func TestStore_ParallelLanesPersistSeparateRows_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, conn := newTestStore(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
laneA := laneGraph("web", 1, "turn-a", "标记A", "答复A")
|
||||
laneB := laneGraph("web", 2, "turn-b", "标记B", "答复B")
|
||||
if laneA.ID == laneB.ID {
|
||||
t.Fatalf("夹具自身就撞了 id:%q", laneA.ID)
|
||||
}
|
||||
for _, g := range []*trace.FlowGraph{laneA, laneB} {
|
||||
if err := st.RecordRound(g); err != nil {
|
||||
t.Fatalf("落盘 %s: %v", g.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := countRows(t, conn, "web"); got != 2 {
|
||||
t.Fatalf("flow_graphs 里该会话的行数 = %d,want 2(两路各一行)", got)
|
||||
}
|
||||
for _, want := range []struct {
|
||||
graph *trace.FlowGraph
|
||||
input string
|
||||
output string
|
||||
}{{laneA, "标记A", "答复A"}, {laneB, "标记B", "答复B"}} {
|
||||
got, err := st.Graph(ctx, "web", want.graph.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查 %s: %v", want.graph.ID, err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("查不到 %s —— 这一路被另一路顶掉了", want.graph.ID)
|
||||
}
|
||||
if got.TurnID != want.graph.TurnID || got.Round != want.graph.Round {
|
||||
t.Fatalf("读回 %s 的路/轮次 = %q/%d,want %q/%d",
|
||||
want.graph.ID, got.TurnID, got.Round, want.graph.TurnID, want.graph.Round)
|
||||
}
|
||||
if !hasNodeLabel(got, want.input) || !hasNodeLabel(got, want.output) {
|
||||
t.Fatalf("读回 %s 的图不是这一路的(输入/回复节点对不上):%+v", want.graph.ID, got.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
turns, err := st.Turns(ctx, "web", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Turns: %v", err)
|
||||
}
|
||||
if len(turns) != 2 {
|
||||
t.Fatalf("Turns 条数 = %d,want 2(两路都能翻到)", len(turns))
|
||||
}
|
||||
if latest, err := st.Latest(ctx, "web"); err != nil || latest == nil {
|
||||
t.Fatalf("Latest = %+v / %v,want 一条真图", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_SameLaneTwiceIsOneRow_边界 钉住唯一键的另一半语义:**同一路**二次收尾
|
||||
// 是同轮覆盖(一行),这正是分桶改造**不该改动**的既有行为 —— 两路分行靠的是两路
|
||||
// 各有各的 id,而不是靠放弃 upsert。
|
||||
func TestStore_SameLaneTwiceIsOneRow_边界(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, conn := newTestStore(t, nil)
|
||||
first := laneGraph("web", 7, "turn-x", "标记A", "答复A")
|
||||
if err := st.RecordRound(first); err != nil {
|
||||
t.Fatalf("首次落盘: %v", err)
|
||||
}
|
||||
again := laneGraph("web", 7, "turn-x", "标记A", "答复A(重放)")
|
||||
if err := st.RecordRound(again); err != nil {
|
||||
t.Fatalf("二次落盘: %v", err)
|
||||
}
|
||||
if got := countRows(t, conn, "web"); got != 1 {
|
||||
t.Fatalf("同一路二次收尾后行数 = %d,want 1(同轮覆盖)", got)
|
||||
}
|
||||
back, err := st.Graph(context.Background(), "web", first.ID)
|
||||
if err != nil || back == nil {
|
||||
t.Fatalf("Graph = %+v / %v", back, err)
|
||||
}
|
||||
if !hasNodeLabel(back, "答复A(重放)") {
|
||||
t.Fatalf("覆盖后的图不是最新那份:%+v", back.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// countRows 数该会话在 flow_graphs 里的行数(结构级断言,不经过 store 的读方法)。
|
||||
func countRows(t *testing.T, conn *sql.DB, session string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM flow_graphs WHERE session_id = ?`, session).Scan(&n); err != nil {
|
||||
t.Fatalf("COUNT 行数: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// hasNodeLabel 报告图里有没有标签等于 label 的节点。
|
||||
func hasNodeLabel(g *trace.FlowGraph, label string) bool {
|
||||
for _, n := range g.Nodes {
|
||||
if n.Label == label {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+210
-51
@@ -2,6 +2,7 @@ package loop
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,9 @@ const flowLogSource = "runtime/loop/flow"
|
||||
// allowedSessions 是用于控制哪些会话可发布
|
||||
// flow 图的谓词签名(主人的 web 会话),使 IM/陌生人/子代理轮次绝不
|
||||
// 泄露进管理面板。谓词为 nil 时允许所有会话。
|
||||
//
|
||||
// 谓词收的是**会话 id**(不是归属桶):调用方先经 splitFlowOwner 拆掉路标识,
|
||||
// 因此白名单永远只按会话判 —— 路的存在绝不放宽或收紧任何一档。
|
||||
type allowedSessions func(sessionID string) bool
|
||||
|
||||
// RoundRecorder 把一条**已收尾轮次**的图落盘(design/19 §4 的 P3「多轮历史回放」;
|
||||
@@ -30,6 +34,10 @@ type allowedSessions func(sessionID string) bool
|
||||
// 上调用),失败**上抛**给发布器(发布器记 WARN 后继续)—— 图是可视化资产,落盘失败
|
||||
// 绝不允许影响会话收尾,也绝不改变实时帧。
|
||||
//
|
||||
// 「每轮一条」在真并行下的含义是**每一路一条**:两路各有自己的图 id(见
|
||||
// trace.RunGraph 的 id 口径),因此 `flow_graphs` 的 `(session_id, turn_id)` 唯一键
|
||||
// 不会被两路撞上(design/15 §2.5.10-1)。
|
||||
//
|
||||
// 白名单不在实现方:EndRound 对发布会话范围外的会话**直接返回**,因此落盘出口天然
|
||||
// 只被白名单内的会话触发(复用 flowSessionAllowed,不存在第二份白名单)。
|
||||
type RoundRecorder interface {
|
||||
@@ -50,22 +58,39 @@ func WithFlowLogger(log *slog.Logger) FlowPublisherOption {
|
||||
return func(p *FlowPublisher) { p.log = log }
|
||||
}
|
||||
|
||||
// FlowPublisher 从循环的 trace span 累积每会话 flow 图,
|
||||
// FlowPublisher 从循环的 trace span 累积**按路**的 flow 图,
|
||||
// 并在每个 span 开始/结束时发布 ws.graph_update,给前端
|
||||
// 一张实时的 input→think→tool→output 图(design/19 §1)。它容忍 nil
|
||||
// (nil 发布者为空操作)且并发安全;会话图
|
||||
// (nil 发布者为空操作)且并发安全;同一会话的图
|
||||
// 在同一把互斥锁下串行化,因为并行的工具 span 会并发完成。
|
||||
//
|
||||
// 归属:一个会话拥有一个实时轮次。声明自己是另一会话分支的
|
||||
// Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
|
||||
// 而是把其 span 发布到所属会话下,因此子代理渲染为
|
||||
// 归属:**一个 Run 拥有一个路 bucket**。单路时 bucket 就是会话 id(行为与引入
|
||||
// 「路」之前逐字相同);真并行时每一路各有自己的 bucket(`{会话}#{路}`,见
|
||||
// FlowOwnerKey),因此两路的节点绝不互相长在对方的图上、也绝不互相重置
|
||||
// (design/15 §2.5.10-1 登记的那条「graph_update 互相顶掉」)。声明自己是另一
|
||||
// 会话分支的 Run(loop.WithFlowBranch)绝不调用 StartRound/EndRound,
|
||||
// 而是把其 span 发布到所属路的 bucket 下,因此子代理渲染为
|
||||
// 父图的一条泳道,而不是第二张图(design/19 §2)。
|
||||
type FlowPublisher struct {
|
||||
mu sync.Mutex
|
||||
publish func(subject string, payload any)
|
||||
allow allowedSessions
|
||||
graphs map[string]*trace.RunGraph
|
||||
rounds map[string]int
|
||||
// graphs 是「归属桶 → 该桶当前这张图」。键见 FlowOwnerKey。
|
||||
graphs map[string]*trace.RunGraph
|
||||
// seq 是**每会话**的建图序号:每次 StartRound 加一,并写进图 id
|
||||
// (`{会话}-{序号}[#{路}]`)。用序号而不是轮次号做 id 的第二个分量,是为了让
|
||||
// 「同一轮里的第二条路」也拿到一个全新的 id —— 否则两路会撞同一个
|
||||
// `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
|
||||
seq map[string]int
|
||||
// rounds 是每会话当前的**轮次号**(= 该轮第一条路的建图序号)。同一轮的各条路
|
||||
// 共享它,客户端据此只保留最新一轮的泳道。
|
||||
rounds map[string]int
|
||||
// roundOf 是「归属桶 → 它属于哪一轮」。
|
||||
roundOf map[string]int
|
||||
// inflight 是每会话**在飞的桶**集合。新一轮的判据是「上一轮的路全部收尾」
|
||||
// (集合为空),因此它是轮次号推进的唯一依据,也是内存不随路数无界增长的
|
||||
// 依据(新一轮起时丢掉更早轮次的桶)。
|
||||
inflight map[string]map[string]bool
|
||||
// record 是每轮收尾的落盘出口(design/19 §4 P3;切片 F2)。nil = 不落盘
|
||||
// (保持 F1 之前的纯内存行为,且测试可只测发布)。
|
||||
record RoundRecorder
|
||||
@@ -81,10 +106,13 @@ type FlowPublisher struct {
|
||||
// 可变项经 Functional Options 传入(不改变既有两参调用):落盘出口(F2)与日志。
|
||||
func NewFlowPublisher(publish func(subject string, payload any), allow func(sessionID string) bool, opts ...FlowPublisherOption) *FlowPublisher {
|
||||
p := &FlowPublisher{
|
||||
publish: publish,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
rounds: make(map[string]int),
|
||||
publish: publish,
|
||||
allow: allow,
|
||||
graphs: make(map[string]*trace.RunGraph),
|
||||
seq: make(map[string]int),
|
||||
rounds: make(map[string]int),
|
||||
roundOf: make(map[string]int),
|
||||
inflight: make(map[string]map[string]bool),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
@@ -94,40 +122,83 @@ func NewFlowPublisher(publish func(subject string, payload any), allow func(sess
|
||||
return p
|
||||
}
|
||||
|
||||
// StartRound 为 sessionID 开始一张新图,用用户文本
|
||||
// 播种一个 input 节点并发布初始帧。它重置该会话的上一
|
||||
// 轮次,因此每个用户 turn 都是自己的一张图。
|
||||
func (p *FlowPublisher) StartRound(sessionID, inputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
// StartRound 为 owner 开始一张新图,用用户文本
|
||||
// 播种一个 input 节点并发布初始帧。
|
||||
//
|
||||
// owner 是**归属桶**(`{会话}[#{路}]`,见 FlowOwnerKey):单路调用方传的就是会话
|
||||
// id(引入本切片之前的所有调用点原样可用),真并行路由经 flowOwner 传
|
||||
// `{会话}#{路}`。两条路各起各的图 ⇒ 后起的那一路绝不重置前一路的图。
|
||||
//
|
||||
// 轮次号只在**上一轮的路全部收尾**之后推进(inflight 为空),因此「同一条消息
|
||||
// 引出的两路」共享同一个轮次号;新一轮开始时丢掉更早轮次的桶,内存因此不随路数
|
||||
// 无界增长(图的历史在落盘里,见 flowgraph 包)。
|
||||
func (p *FlowPublisher) StartRound(owner, inputText string) {
|
||||
session, lane := splitFlowOwner(owner)
|
||||
if session == "" || owner == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
round := p.rounds[sessionID] + 1
|
||||
g := trace.NewRunGraph(sessionID)
|
||||
round := p.beginRoundLocked(session, owner)
|
||||
g := trace.NewRunGraph(session)
|
||||
g.SetTurnID(lane)
|
||||
g.SetSeq(p.seq[session])
|
||||
g.Start(round, time.Now())
|
||||
g.SeedInput(inputText)
|
||||
p.graphs[sessionID] = g
|
||||
p.rounds[sessionID] = round
|
||||
p.graphs[owner] = g
|
||||
p.roundOf[owner] = round
|
||||
if p.inflight[session] == nil {
|
||||
p.inflight[session] = make(map[string]bool, 2)
|
||||
}
|
||||
p.inflight[session][owner] = true
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// beginRoundLocked 分配(或沿用)本轮次号并清掉更早轮次的桶。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) beginRoundLocked(session, owner string) int {
|
||||
p.seq[session]++
|
||||
if len(p.inflight[session]) == 0 {
|
||||
p.rounds[session] = p.seq[session]
|
||||
p.dropEarlierRoundsLocked(session)
|
||||
}
|
||||
return p.rounds[session]
|
||||
}
|
||||
|
||||
// dropEarlierRoundsLocked 丢掉该会话**更早轮次**留下的桶与图。图的历史由落盘承载
|
||||
// (`flow_graphs`),实时桶只需覆盖当前这一轮 —— 否则每一条并行路(turnId 每回合
|
||||
// 全新、bucket 键因此永不复用)都会在内存里永久留一张图。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) dropEarlierRoundsLocked(session string) {
|
||||
current := p.rounds[session]
|
||||
for owner, round := range p.roundOf {
|
||||
if round >= current {
|
||||
continue
|
||||
}
|
||||
if s, _ := splitFlowOwner(owner); s != session {
|
||||
continue
|
||||
}
|
||||
delete(p.roundOf, owner)
|
||||
delete(p.graphs, owner)
|
||||
}
|
||||
}
|
||||
|
||||
// EndRound 用回复播种一个 output 节点并发布最终帧,
|
||||
// 以此收尾该轮次。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph`**(网关只读查询,契约 v0.71),而不是又发一帧:
|
||||
// 实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取补齐
|
||||
// (`graph_update` 本身不重放)。
|
||||
// 以此收尾该路的图。它保留该图,使错过最终帧的客户端能重放最后一份快照 ——
|
||||
// **重放的出口是 `LastGraph` / `LatestLanes`**(网关只读查询,契约 v0.71/v0.77),
|
||||
// 而不是又发一帧:实时帧向来只推给当时在线的连接,刷新/晚开的页面只能靠只读拉取
|
||||
// 补齐(`graph_update` 本身不重放)。
|
||||
//
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮落
|
||||
// **一条**(绝不是每 span 一条),且只在该会话处于发布会话白名单内时发生(本方法对
|
||||
// 白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话触发)。深拷贝与写库在**放锁
|
||||
// 之后**做:一次库写绝不把其他会话的 span 发布挡住;失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if sessionID == "" || !p.allowed(sessionID) {
|
||||
// 收尾也是**落盘**的唯一时机(切片 F2;design/19 §4 的 P3「多轮历史回放」):每轮
|
||||
// 落**一条**(绝不是每 span 一条),真并行下**每一路各一条**;且只在该会话处于发布
|
||||
// 会话白名单内时发生(本方法对白名单外的会话直接返回 ⇒ 落盘出口天然只被白名单会话
|
||||
// 触发)。深拷贝与写库在**放锁之后**做:一次库写绝不把其他会话的 span 发布挡住;
|
||||
// 失败只记 WARN,绝不影响本轮收尾。
|
||||
func (p *FlowPublisher) EndRound(owner, outputText string) {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if session == "" || owner == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
@@ -138,29 +209,33 @@ func (p *FlowPublisher) EndRound(sessionID, outputText string) {
|
||||
if p.record != nil {
|
||||
snap = g.Snapshot().Clone()
|
||||
}
|
||||
// 本路退出在飞集合(**只删自己的桶**):集合空了才说明整轮收尾,下一轮才会
|
||||
// 拿到新的轮次号 —— 因此轮次号在同轮并行的两路之间保持同一个值。
|
||||
delete(p.inflight[session], owner)
|
||||
p.mu.Unlock()
|
||||
|
||||
if snap != nil {
|
||||
if err := p.record.RecordRound(snap); err != nil {
|
||||
p.logPersistError(sessionID, snap.ID, err)
|
||||
p.logPersistError(session, snap.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkInputSteered 给本轮的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点打 steered:true 标记」)。
|
||||
// 它让链路图如实表达「这一轮的输入在回合中途被追加过」——图仍然只有一条主干
|
||||
// MarkInputSteered 给本路的 input 节点打上 `steered:true` 标记并发布一帧
|
||||
// (design/15 §5 / 裁决 13.4.5「注入时给 input 节点贴 steered:true 标记」)。
|
||||
// 它让链路图如实表达「这一路的输入在回合中途被追加过」——图仍然只有一条主干
|
||||
// (I1:同会话恒只有一个在飞回合),而不是被画成第二轮。
|
||||
//
|
||||
// nil 接收者、空会话、发布白名单外的会话、或该会话还没有图时都是空操作;标记
|
||||
// nil 接收者、空归属、发布白名单外的会话、或该路还没有图时都是空操作;标记
|
||||
// 只并入既有 input 节点(Upsert 的合并语义),绝不新建节点、绝不改动它的标签。
|
||||
func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||||
if p == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
func (p *FlowPublisher) MarkInputSteered(owner string) {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if p == nil || owner == "" || session == "" || !p.allowed(session) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -174,13 +249,13 @@ func (p *FlowPublisher) MarkInputSteered(sessionID string) {
|
||||
|
||||
// SpanStarted 为一个刚开始的 span 记录一个运行中的占位
|
||||
// 节点。state_transition span 会被图构建器省略。
|
||||
func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
func (p *FlowPublisher) SpanStarted(owner string, sp *trace.Span) {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.bucketLocked(owner)
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -190,14 +265,14 @@ func (p *FlowPublisher) SpanStarted(sessionID string, sp *trace.Span) {
|
||||
}
|
||||
|
||||
// SpanFinished 把一个刚结束的 span 作为最终节点(状态 done/error)
|
||||
// 并入会话图并发布。必须在 span 的 Finish 之后调用。
|
||||
func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
if sp == nil || sessionID == "" || !p.allowed(sessionID) {
|
||||
// 并入该路的图并发布。必须在 span 的 Finish 之后调用。
|
||||
func (p *FlowPublisher) SpanFinished(owner string, sp *trace.Span) {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
g := p.bucketLocked(owner)
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
@@ -205,16 +280,30 @@ func (p *FlowPublisher) SpanFinished(sessionID string, sp *trace.Span) {
|
||||
p.publishLocked(g)
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一轮**链路图的一份只读深拷贝快照,供
|
||||
// bucketLocked 取归属桶当前那张图;归属非法或该路还没有图时返回 nil。调用方必须
|
||||
// 持有 p.mu。白名单判据与建图处同一处(bucket 拆出的会话),因此**不可能**让
|
||||
// 白名单外的会话经 span 折叠进图。
|
||||
func (p *FlowPublisher) bucketLocked(owner string) *trace.RunGraph {
|
||||
session, _ := splitFlowOwner(owner)
|
||||
if owner == "" || session == "" || !p.allowed(session) {
|
||||
return nil
|
||||
}
|
||||
return p.graphs[owner]
|
||||
}
|
||||
|
||||
// LastGraph 返回 sessionID **最近一路**链路图的一份只读深拷贝快照,供
|
||||
// 管理面板在「轮次已经结束、实时帧已经错过」时补拉(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph`;design/19 §4 的 P3「多轮历史回放」的
|
||||
// 最小一步)。
|
||||
//
|
||||
// 「最近」= 该会话所有在内存的路里**开始最晚**的那一路(同刻用图 id 定序)。
|
||||
// 真并行时它只是最近的一路,**全貌**是 `LatestLanes`(契约 v0.77 的 `lanes[]`)。
|
||||
//
|
||||
// 它是**只读**的:不发布、不改图、不动轮次计数 —— 因此补拉绝不会在
|
||||
// 管理面板里凭空造出一帧 `graph_update`,也绝不改任何会话的实时图。
|
||||
//
|
||||
// 该会话还没有图时返回 nil:从未跑过带 span 的轮次、或该会话本就**不在
|
||||
// 发布会话范围内**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
|
||||
// 发布会话范围**(`StartRound` 对白名单外的会话直接返回 ⇒ 从不建图,
|
||||
// 隔离渠道会话因此天然查不到)。调用方**必须**把 nil 读作「确实没有图」,
|
||||
// 绝不允许据此补画任何节点或假图。
|
||||
//
|
||||
@@ -226,11 +315,81 @@ func (p *FlowPublisher) LastGraph(sessionID string) *trace.FlowGraph {
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g := p.graphs[sessionID]
|
||||
if g == nil {
|
||||
latest := p.latestLocked(sessionID)
|
||||
if latest == nil {
|
||||
return nil
|
||||
}
|
||||
return g.Snapshot().Clone()
|
||||
return latest.Clone()
|
||||
}
|
||||
|
||||
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(每条路一份深拷贝),
|
||||
// 按路的开始时间升序(最早的在前)。契约 v0.77 的 `lanes[]` 就是它:真并行时
|
||||
// 同一会话同一轮有多条路,`LastGraph` 只给出其中最新的一条,管理面板要靠本方法
|
||||
// 才看得全(design/19 §2「并行分支独立显示」)。
|
||||
//
|
||||
// 没有图时返回空切片(不是 nil):调用方据此渲染诚实空态,绝不补画。
|
||||
// 与 LastGraph 一样是**只读**的,且返回深拷贝(读者在锁外编码)。
|
||||
func (p *FlowPublisher) LatestLanes(sessionID string) []*trace.FlowGraph {
|
||||
if p == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
round := p.rounds[sessionID]
|
||||
owners := make([]string, 0, 2)
|
||||
for owner, r := range p.roundOf {
|
||||
if r != round {
|
||||
continue
|
||||
}
|
||||
if s, _ := splitFlowOwner(owner); s == sessionID {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
}
|
||||
out := make([]*trace.FlowGraph, 0, len(owners))
|
||||
for _, owner := range owners {
|
||||
g := p.graphs[owner]
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, g.Snapshot().Clone())
|
||||
}
|
||||
sortLanes(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// latestLocked 取该会话开始最晚的那一路的活图(没有时 nil)。调用方必须持有 p.mu。
|
||||
func (p *FlowPublisher) latestLocked(sessionID string) *trace.FlowGraph {
|
||||
var best *trace.FlowGraph
|
||||
for owner, g := range p.graphs {
|
||||
if s, _ := splitFlowOwner(owner); s != sessionID {
|
||||
continue
|
||||
}
|
||||
snap := g.Snapshot()
|
||||
if best == nil || laneNewer(snap, best) {
|
||||
best = snap
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// laneNewer 报告 a 是否比 b 更「新」:先比开始时刻,同刻用图 id 定序(图 id 里含
|
||||
// 会话级单调序号,因此定序不依赖时间分辨率)。
|
||||
func laneNewer(a, b *trace.FlowGraph) bool {
|
||||
if a.StartedAt.Equal(b.StartedAt) {
|
||||
return a.ID > b.ID
|
||||
}
|
||||
return a.StartedAt.After(b.StartedAt)
|
||||
}
|
||||
|
||||
// sortLanes 按「开始时刻升序、同刻按图 id」把一路路图排序,使一个轮次里的多条路
|
||||
// 有一个确定的展示次序(绝不让展示次序随 map 遍历序抖动)。
|
||||
func sortLanes(lanes []*trace.FlowGraph) {
|
||||
sort.SliceStable(lanes, func(i, j int) bool {
|
||||
if lanes[i].StartedAt.Equal(lanes[j].StartedAt) {
|
||||
return lanes[i].ID < lanes[j].ID
|
||||
}
|
||||
return lanes[i].StartedAt.Before(lanes[j].StartedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// publishLocked 发布 g 的一份快照。调用方必须持有 p.mu。
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
package loop
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flowOwnerSep 是 flow 图**归属桶**(`{会话}#{路}`)的分隔符。它与
|
||||
// design/15 §2.5.9 的 checkpoint 归属键(`{sessionID}#{turnID}`)以及
|
||||
// trace 的图 id 拼接口径(`{会话}-{轮次}#{路}`)取同一个字符:**一条路**
|
||||
// 在流程图、checkpoint 与落盘行三处因此是同一个标识。
|
||||
//
|
||||
// 之所以能在同一个字符串里编码「会话 + 路」:会话 id 的取值族是
|
||||
// `web` / `web:<uuid>` / `sess-backthink`(`cmd/cyrene` 的 flowSessionAllowed),
|
||||
// 权威 turnId 是 `turn-<hex>`(`gateway.newTurnID`),两者都不含 `#`。
|
||||
const flowOwnerSep = "#"
|
||||
|
||||
// FlowOwnerKey 拼出「会话[#路]」的 flow 归属桶;lane 为空时就是会话本身
|
||||
// (单路 / 未声明并行的运行,逐字等于引入本特性之前的会话 id)。
|
||||
//
|
||||
// 导出它是因为**归属桶的串形状只有一个权威**:发布器的建图出口、子代理 span 的
|
||||
// 归属、以及跨包的验收测试都按它拼串,任何一处自己拼 `#` 都会在换分隔符时静默走偏。
|
||||
func FlowOwnerKey(sessionID, lane string) string {
|
||||
if lane == "" {
|
||||
return sessionID
|
||||
}
|
||||
return sessionID + flowOwnerSep + lane
|
||||
}
|
||||
|
||||
// splitFlowOwner 把归属桶拆回(会话, 路)。没有 `#` 时整串就是会话、路为空 ——
|
||||
// 这正是单路运行与**旧调用方**传给发布器的形态,因此拆分是向后兼容的。
|
||||
func splitFlowOwner(owner string) (string, string) {
|
||||
if i := strings.Index(owner, flowOwnerSep); i >= 0 {
|
||||
return owner[:i], owner[i+1:]
|
||||
}
|
||||
return owner, ""
|
||||
}
|
||||
|
||||
// flowBranchKey 是标记某个 Run 为另一会话 flow 图分支的 context key。
|
||||
// 它由 orchestration 层(经 WithFlowBranch)在驱动
|
||||
@@ -8,24 +42,28 @@ import "context"
|
||||
type flowBranchKey struct{}
|
||||
|
||||
// WithFlowBranch 返回 ctx,把当前 Run 标记为分支:它的 span 并入
|
||||
// parentSession 的实时 flow 图,且该 Run 自身既不开始也不结束
|
||||
// parent 的实时 flow 图,且该 Run 自身既不开始也不结束
|
||||
// 轮次 —— 由所属会话的轮次负责。orchestration 层正是靠它把
|
||||
// 子代理的工作归属到派生它的会话,使并行的
|
||||
// 子代理渲染为同一张图的泳道,而不是用各自的图
|
||||
// 替换前端唯一的 flowGraph(design/19 §2)。
|
||||
//
|
||||
// 空的 parentSession 仍把该 Run 标记为分支:它不拥有轮次、
|
||||
// parent 是**归属桶**(`{会话}#{路}`,见 FlowOwnerKey):并行路里派发的子代理必须
|
||||
// 并进**那一路**的图,而不是会话主干那张 —— 否则两路的子代理节点会混进同一张图,
|
||||
// 正是本切片要治的「两路互相覆盖」(design/15 §2.5.10-1)。单路时它逐字等于会话 id。
|
||||
//
|
||||
// 空的 parent 仍把该 Run 标记为分支:它不拥有轮次、
|
||||
// 不发布任何东西。这正是无人归属到会话的后台子代理的
|
||||
// 状态,它不得悄悄变成属于自己的一张图。nil ctx
|
||||
// 原样返回。
|
||||
func WithFlowBranch(ctx context.Context, parentSession string) context.Context {
|
||||
func WithFlowBranch(ctx context.Context, parent string) context.Context {
|
||||
if ctx == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, flowBranchKey{}, parentSession)
|
||||
return context.WithValue(ctx, flowBranchKey{}, parent)
|
||||
}
|
||||
|
||||
// flowBranch 报告 WithFlowBranch 记录的父会话以及
|
||||
// flowBranch 报告 WithFlowBranch 记录的父归属桶以及
|
||||
// 该 Run 是否本就是分支。非分支的 Run 拥有自己的轮次并在
|
||||
// 自己的 session id 下发布。
|
||||
func flowBranch(ctx context.Context) (string, bool) {
|
||||
@@ -40,27 +78,55 @@ func flowBranch(ctx context.Context) (string, bool) {
|
||||
return s, true
|
||||
}
|
||||
|
||||
// flowOwner 返回应当接收该 Run span 的 flow 图所属会话:
|
||||
// flowOwner 返回应当接收该 Run span 的 flow 归属桶:
|
||||
// 当该 Run 是子代理分支时取分支父级(分支未归属时为空,
|
||||
// 此时抑制发布),否则取该 Run 自身的 sessionID。
|
||||
// 此时抑制发布),否则取该 Run 自身的 session 加上它所属的**路**
|
||||
// (真并行路的标识由网关经 WithParallelLeg 盖上;单路时为会话本身)。
|
||||
func flowOwner(ctx context.Context, sessionID string) string {
|
||||
if parent, isBranch := flowBranch(ctx); isBranch {
|
||||
return parent
|
||||
}
|
||||
return sessionID
|
||||
return FlowOwnerKey(sessionID, ParallelLegFrom(ctx))
|
||||
}
|
||||
|
||||
// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图所属会话:
|
||||
// 当 ctx 运行于子代理分支内时取分支父级,否则
|
||||
// 取外层 Run 被驱动的 session id(两者都未设置时为空)。
|
||||
// FlowOwnerFromContext 返回拥有从 ctx 启动的工作的 flow 图**归属桶**
|
||||
// (`{会话}#{路}`,见 FlowOwnerKey):当 ctx 运行于子代理分支内时取分支父级,
|
||||
// 否则取外层 Run 被驱动的 session id 加上它所属的路(两者都未设置时为空)。
|
||||
//
|
||||
// 派发后台工作的工具 —— 子代理派生工具 —— 把它
|
||||
// 作为父会话传入,使在另一个子代理内部派生的子代理
|
||||
// 保持在同一个顶层图的归属之下,而不是归属到一个不拥有
|
||||
// 任何图的 id。
|
||||
// 派发后台工作的工具 —— 子代理派生工具 —— 把它作为父归属桶传入,使在另一个
|
||||
// 子代理内部派生的子代理保持在同一个顶层图的归属之下,而不是归属到一个不拥有
|
||||
// 任何图的 id;真并行路里派发的子代理也因此落进**那一路**的图。
|
||||
//
|
||||
// ⚠️ 它返回的是**归属桶**而不是会话 id。要把子代理的归属会话交给总线载荷
|
||||
// (`subagent_result` 的 sessionId 必须是真会话 id)时必须用
|
||||
// FlowSessionFromContext 取会话那一半。
|
||||
func FlowOwnerFromContext(ctx context.Context) string {
|
||||
if parent, isBranch := flowBranch(ctx); isBranch {
|
||||
return parent
|
||||
if owner, isBranch := flowBranch(ctx); isBranch {
|
||||
return owner
|
||||
}
|
||||
return SessionFromContext(ctx)
|
||||
session := SessionFromContext(ctx)
|
||||
if session == "" {
|
||||
// 没有会话就没有归属:绝不回一个 `#路` 形态的串(那既查不到图,也会让
|
||||
// 调用方误以为有归属)。分支与子代理因此保持「不发布」的既有语义。
|
||||
return ""
|
||||
}
|
||||
return FlowOwnerKey(session, FlowLaneFromContext(ctx))
|
||||
}
|
||||
|
||||
// FlowSessionFromContext 返回从 ctx 启动的工作的**归属会话 id**(不含路):
|
||||
// 真并行时同一会话的每一路都是同一个会话 id,总线载荷与日志要的就是它。
|
||||
func FlowSessionFromContext(ctx context.Context) string {
|
||||
session, _ := splitFlowOwner(FlowOwnerFromContext(ctx))
|
||||
return session
|
||||
}
|
||||
|
||||
// FlowLaneFromContext 返回从 ctx 启动的工作所属的**路**标识(design/15 §2.5.9
|
||||
// 的 turnId);单路 / 未声明并行时为空。分支运行取分支归属桶里的那一半(子代理
|
||||
// 因此继承派生它的那一路),非分支运行取本 Run 的并发路。
|
||||
func FlowLaneFromContext(ctx context.Context) string {
|
||||
if owner, isBranch := flowBranch(ctx); isBranch {
|
||||
_, lane := splitFlowOwner(owner)
|
||||
return lane
|
||||
}
|
||||
return ParallelLegFrom(ctx)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住**按路分桶**这条改造的其余口径(核心行为已在 flow_parallel_test.go 用真
|
||||
// 循环跑过):桶 / 图 id / 帧上 `turnId` 键三者同源,最近一轮的每一路都拿得回来,
|
||||
// 且新一轮开始后不再把上一轮的路留在内存里。这些用例走发布器的公开面(真调用点
|
||||
// 传给它的就是这些字符串),不依赖任何数据库。
|
||||
|
||||
// laneIDs 返回每一路图的 id 与 turnId 键,供断言用。
|
||||
func laneIDs(lanes []*trace.FlowGraph) []string {
|
||||
out := make([]string, 0, len(lanes))
|
||||
for _, g := range lanes {
|
||||
out = append(out, g.ID+"@"+g.TurnID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestFlowPublisher_ParallelLanesGetOwnGraphs 钉住真并行两路各有各的桶:
|
||||
// 图 id 不同(落盘不撞 key)、都归属同一个会话、都带上自己的路标识(帧上 `turnId`),
|
||||
// 且最近一轮的两路都能经 LatestLanes 拿回来。
|
||||
func TestFlowPublisher_ParallelLanesGetOwnGraphs(t *testing.T) {
|
||||
p := NewFlowPublisher(nil, func(string) bool { return true })
|
||||
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
|
||||
p.SpanStarted(FlowOwnerKey("web", "turn-a"), spanNamed("span-a", "tool.probe_a"))
|
||||
p.SpanFinished(FlowOwnerKey("web", "turn-a"), finishedSpan("span-a", "tool.probe_a"))
|
||||
|
||||
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
|
||||
p.SpanStarted(FlowOwnerKey("web", "turn-b"), spanNamed("span-b", "tool.probe_b"))
|
||||
p.SpanFinished(FlowOwnerKey("web", "turn-b"), finishedSpan("span-b", "tool.probe_b"))
|
||||
|
||||
ga := p.LastGraph("web")
|
||||
if ga == nil {
|
||||
t.Fatal("LastGraph = nil,但该会话刚起了两路")
|
||||
}
|
||||
// 最近一路 = 后起的那一路;它的图里只有自己的工具。
|
||||
if ga.TurnID != "turn-b" || !hasNode(ga, "probe_b") || hasNode(ga, "probe_a") {
|
||||
t.Fatalf("LastGraph 不是 B 那一路的图:id=%s turnId=%q nodes=%s", ga.ID, ga.TurnID, describeNodes(ga))
|
||||
}
|
||||
|
||||
lanes := p.LatestLanes("web")
|
||||
if len(lanes) != 2 {
|
||||
t.Fatalf("最近一轮的路数 = %d,want 2(两路各自一张图):%v", len(lanes), laneIDs(lanes))
|
||||
}
|
||||
if got := laneIDs(lanes); got[0] != "web-1#turn-a@turn-a" || got[1] != "web-2#turn-b@turn-b" {
|
||||
t.Fatalf("两路的 id@turnId = %v,want [web-1#turn-a@turn-a web-2#turn-b@turn-b]", got)
|
||||
}
|
||||
if lanes[0].ID == lanes[1].ID {
|
||||
t.Fatalf("两路撞同一个图 id %q(落盘会互相顶掉)", lanes[0].ID)
|
||||
}
|
||||
for _, g := range lanes {
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("图 %s 的 sessionId = %q,want web(路标识绝不改变会话归属)", g.ID, g.SessionID)
|
||||
}
|
||||
if g.Round != lanes[0].Round {
|
||||
t.Fatalf("同一轮的两路轮次号不同:%d vs %d", g.Round, lanes[0].Round)
|
||||
}
|
||||
}
|
||||
// 收尾后仍是最近一轮的每一路(页面在这之后才打开也看得到两条)。
|
||||
p.EndRound(FlowOwnerKey("web", "turn-a"), "答复A")
|
||||
p.EndRound(FlowOwnerKey("web", "turn-b"), "答复B")
|
||||
if got := p.LatestLanes("web"); len(got) != 2 {
|
||||
t.Fatalf("收轮后最近一轮的路数 = %d,want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_NewRoundDropsEarlierLanes 钉住轮次推进与内存边界:新一轮只在
|
||||
// 上一轮的路全部收尾之后开始(该轮的路因此共享轮次号),并丢掉更早轮次的桶 ——
|
||||
// 否则每一条真并行的路(turnId 每回合全新、桶键永不复用)都会在内存里永久留一张图。
|
||||
func TestFlowPublisher_NewRoundDropsEarlierLanes(t *testing.T) {
|
||||
p := NewFlowPublisher(nil, func(string) bool { return true })
|
||||
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
|
||||
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
|
||||
round := p.LatestLanes("web")[0].Round
|
||||
p.EndRound(FlowOwnerKey("web", "turn-a"), "答复A")
|
||||
p.EndRound(FlowOwnerKey("web", "turn-b"), "答复B")
|
||||
|
||||
// 上一轮尚未被新轮次顶掉时,两条路都还在。
|
||||
if got := p.LatestLanes("web"); len(got) != 2 {
|
||||
t.Fatalf("上一轮的路数 = %d,want 2(收轮后仍可补拉)", len(got))
|
||||
}
|
||||
|
||||
p.StartRound("web", "单路的下一轮")
|
||||
next := p.LatestLanes("web")
|
||||
if len(next) != 1 || next[0].TurnID != "" {
|
||||
t.Fatalf("新一轮的路 = %v,want 恰好一条无路标识的图(上一轮的桶必须被丢掉)", laneIDs(next))
|
||||
}
|
||||
if next[0].Round <= round {
|
||||
t.Fatalf("新一轮轮次号 = %d,want > %d", next[0].Round, round)
|
||||
}
|
||||
if g := p.LastGraph("web"); g == nil || g.TurnID != "" {
|
||||
t.Fatalf("新一轮的 LastGraph = %+v,want 单路那张(上一轮的路已不在内存)", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_RoundsShareRoundNumberButNeverID 钉住「轮次号」与「图 id 的序号」
|
||||
// 是两个量:同一轮里可以有**不止一条路**(真并行的那一路 + 队首续跑的串行路),它们
|
||||
// 共享轮次号(客户端据此把同一轮的路并排画),但**各有各的图 id** —— 否则落盘的
|
||||
// `(session_id, turn_id)` 唯一键会把同一会话的两轮 upsert 成一行,前一轮整张图消失。
|
||||
func TestFlowPublisher_RoundsShareRoundNumberButNeverID(t *testing.T) {
|
||||
rec := &fakeRecorder{}
|
||||
p := NewFlowPublisher(nil, func(string) bool { return true }, WithRoundRecorder(rec))
|
||||
// 一条并行路先起(占用本轮),随后两条串行路在它仍飞着的时候先后跑完。
|
||||
p.StartRound(FlowOwnerKey("web", "turn-a"), "并行的一路")
|
||||
p.StartRound("web", "串行第一轮")
|
||||
p.EndRound("web", "答复1")
|
||||
p.StartRound("web", "串行第二轮")
|
||||
p.EndRound("web", "答复2")
|
||||
|
||||
lines := recordedRounds(rec)
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("落盘 %d 条,want 2(两条串行路各一条)", len(lines))
|
||||
}
|
||||
if lines[0].ID == lines[1].ID {
|
||||
t.Fatalf("同一轮的两条串行路撞同一个 id %q ⇒ 落盘会把前一轮整行顶掉", lines[0].ID)
|
||||
}
|
||||
if lines[0].Round != lines[1].Round {
|
||||
t.Fatalf("同一轮的两条路轮次号不同:%d vs %d", lines[0].Round, lines[1].Round)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowPublisher_SingleLegUnchanged 钉住**单路逐字不变**:归属桶就是会话 id,
|
||||
// 图 id 仍是 `{会话}-{轮次}`(没有路后缀),帧上 `turnId` 键缺席。既有回归
|
||||
// (flow_snapshot_test.go 的 web-1/web-2)与本条是同一口径的两侧。
|
||||
func TestFlowPublisher_SingleLegUnchanged(t *testing.T) {
|
||||
p := NewFlowPublisher(nil, func(string) bool { return true })
|
||||
p.StartRound("web", "你好")
|
||||
p.EndRound("web", "你也好")
|
||||
g := p.LastGraph("web")
|
||||
if g == nil || g.ID != "web-1" || g.TurnID != "" {
|
||||
t.Fatalf("单路图 = %+v,want id=web-1 且无 turnId(逐字不变)", g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowOwnershipHelpers 钉住 loop 与 orchestration 之间的归属口径:
|
||||
// 真并行路由 `{会话}#{路}` 归属、真会话 id 与路标识各自可单独取出(总线载荷要的是
|
||||
// 会话、图归属要的是桶),子代理分支继承派生它的那一路。
|
||||
func TestFlowOwnershipHelpers(t *testing.T) {
|
||||
// 与 Run 同形:会话先经 withLoopSession 播种,网关再加路标识(WithParallelLeg)。
|
||||
ctx := withLoopSession(WithParallelLeg(WithTurnID(context.Background(), "turn-a"), "turn-a"), "web")
|
||||
if got := flowOwner(ctx, "web"); got != "web#turn-a" {
|
||||
t.Fatalf("并行路的归属桶 = %q,want web#turn-a", got)
|
||||
}
|
||||
if got := FlowOwnerFromContext(ctx); got != "web#turn-a" {
|
||||
t.Fatalf("FlowOwnerFromContext = %q,want web#turn-a", got)
|
||||
}
|
||||
if got := FlowSessionFromContext(ctx); got != "web" {
|
||||
t.Fatalf("FlowSessionFromContext = %q,want web(总线载荷要真会话 id)", got)
|
||||
}
|
||||
if got := FlowLaneFromContext(ctx); got != "turn-a" {
|
||||
t.Fatalf("FlowLaneFromContext = %q,want turn-a", got)
|
||||
}
|
||||
|
||||
// 子代理(分支 Run):归属继承分支桶,会话与路各自拆得回来。
|
||||
branch := WithFlowBranch(context.Background(), FlowOwnerFromContext(ctx))
|
||||
if got := flowOwner(branch, "sub-1"); got != "web#turn-a" {
|
||||
t.Fatalf("子代理 span 的归属桶 = %q,want web#turn-a(必须落进派生它的那一路)", got)
|
||||
}
|
||||
if got := FlowSessionFromContext(branch); got != "web" {
|
||||
t.Fatalf("子代理的 FlowSessionFromContext = %q,want web", got)
|
||||
}
|
||||
if got := FlowLaneFromContext(branch); got != "turn-a" {
|
||||
t.Fatalf("子代理的 FlowLaneFromContext = %q,want turn-a", got)
|
||||
}
|
||||
|
||||
// 单路:桶、会话、路三者不出意外的形态(路为空)。
|
||||
single := withLoopSession(context.Background(), "web")
|
||||
if got := flowOwner(single, "web"); got != "web" {
|
||||
t.Fatalf("单路归属桶 = %q,want web", got)
|
||||
}
|
||||
if got := FlowOwnerFromContext(single); got != "web" {
|
||||
t.Fatalf("单路 FlowOwnerFromContext = %q,want web", got)
|
||||
}
|
||||
if got := FlowLaneFromContext(single); got != "" {
|
||||
t.Fatalf("单路 FlowLaneFromContext = %q,want 空", got)
|
||||
}
|
||||
// 没有会话就没有归属:绝不回 `#路` 这种既查不到图、又骗调用方的串。
|
||||
if got := FlowOwnerFromContext(WithParallelLeg(context.Background(), "turn-a")); got != "" {
|
||||
t.Fatalf("无会话时 FlowOwnerFromContext = %q,want 空", got)
|
||||
}
|
||||
if got := FlowSessionFromContext(WithParallelLeg(context.Background(), "turn-a")); got != "" {
|
||||
t.Fatalf("无会话时 FlowSessionFromContext = %q,want 空", got)
|
||||
}
|
||||
if _, lane := splitFlowOwner("web"); lane != "" {
|
||||
t.Fatalf("无 # 的归属串应拆出空路,得到 %q", lane)
|
||||
}
|
||||
}
|
||||
|
||||
// spanNamed / finishedSpan 造一个刚起 / 刚结束的 span,让图里有真实的节点。
|
||||
func spanNamed(id, name string) *trace.Span {
|
||||
return &trace.Span{ID: id, Name: name, Kind: trace.SpanKindTool}
|
||||
}
|
||||
|
||||
func finishedSpan(id, name string) *trace.Span {
|
||||
sp := spanNamed(id, name)
|
||||
sp.Finish(trace.SpanStatusOK)
|
||||
return sp
|
||||
}
|
||||
|
||||
// TestFlowPublisher_LaneFramesCarryLaneKey 钉住**帧载荷**上的 `turnId` 键:
|
||||
// 客户端据此把两路的帧分成两条泳道(旧客户端忽略未知键仍可用)。
|
||||
func TestFlowPublisher_LaneFramesCarryLaneKey(t *testing.T) {
|
||||
var frames []*trace.FlowGraph
|
||||
p := NewFlowPublisher(func(_ string, payload any) {
|
||||
if g, ok := payload.(*trace.FlowGraph); ok {
|
||||
frames = append(frames, g)
|
||||
}
|
||||
}, func(string) bool { return true })
|
||||
p.StartRound(FlowOwnerKey("web", "turn-a"), "标记A")
|
||||
p.StartRound(FlowOwnerKey("web", "turn-b"), "标记B")
|
||||
|
||||
if len(frames) < 2 {
|
||||
t.Fatalf("发布帧数 = %d,want ≥2", len(frames))
|
||||
}
|
||||
for _, g := range frames {
|
||||
if g.TurnID == "" {
|
||||
t.Fatalf("并行路的帧缺 turnId 键(客户端无法分成两条泳道):id=%s", g.ID)
|
||||
}
|
||||
if !strings.Contains(g.ID, g.TurnID) {
|
||||
t.Fatalf("图 id %q 里找不到路标识 %q(两者必须同源)", g.ID, g.TurnID)
|
||||
}
|
||||
if g.Round == 0 {
|
||||
t.Fatalf("帧缺轮次号:id=%s", g.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件是**真并行两路互不覆盖**的回归(design/15 §2.5.10-1 登记的那条真 bug):
|
||||
// 同一会话的两条并行路(`chat.parallel`)各自开一轮,`graphs[sessionID]` 只有一张 ⇒
|
||||
// 后起的那一路把前一路的图整张顶掉、两路的节点互相长在对方的图上、落盘两路撞同一个
|
||||
// `flow_graphs.turn_id`。
|
||||
//
|
||||
// 用例刻意只用**引入本切片之前就存在**的 API 驱动真实路径(真 `Loop.Run` + 网关实际
|
||||
// 挂的那两条 ctx 通道 `WithTurnID` / `WithParallelLeg`),因此它在修复前能编译、能跑、
|
||||
// 会红 —— 「改前红」的证据就是它(见 FV2-report §4)。判据:
|
||||
//
|
||||
// - 两条路各落一条(`RecordRound` 两次)且**图 id 不同**(同一 turn_id 会被
|
||||
// `(session_id, turn_id)` 唯一键 upsert 成一行);
|
||||
// - 以 A 的输入播种的那张图里只有 A 的工具节点,反之亦然;
|
||||
// - 任何一帧 `graph_update` 都不同时含两条路的工具节点。
|
||||
|
||||
// barrierTool 是执行期停在闸门上的工具:它报告「已到执行点」后阻塞,使测试能确定地
|
||||
// 构造「A 的工具在飞时 B 起步」的交错时序,而不是靠 sleep 赌调度。
|
||||
type barrierTool struct {
|
||||
name string
|
||||
arrived chan struct{}
|
||||
gate chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newBarrierTool(name string) *barrierTool {
|
||||
return &barrierTool{name: name, arrived: make(chan struct{}, 1), gate: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (b *barrierTool) Name() string { return b.name }
|
||||
func (b *barrierTool) Description() string { return "barrier tool " + b.name }
|
||||
|
||||
func (b *barrierTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object", "properties": map[string]any{}}, nil
|
||||
}
|
||||
|
||||
func (b *barrierTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
b.once.Do(func() { b.arrived <- struct{}{} })
|
||||
select {
|
||||
case <-b.gate:
|
||||
return b.name + " 完成", nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// laneAdapter 按「这一路的窗口里是哪个标记文本」分流,先请求该路的工具,工具结果回灌
|
||||
// 之后给出该路的最终答复。两条路共用一个实例 —— 与生产同形(同一个 runner 跑两路)。
|
||||
type laneAdapter struct {
|
||||
tool map[string]string // 标记文本 → 该路请求的工具名
|
||||
answer map[string]string // 标记文本 → 该路的最终答复
|
||||
}
|
||||
|
||||
func (a *laneAdapter) Name() string { return "lane" }
|
||||
|
||||
func (a *laneAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
mark := a.markOf(req)
|
||||
if mark == "" {
|
||||
return model.Response{Content: "无法归属的请求"}, nil
|
||||
}
|
||||
if lastMessageRole(req.Messages) == model.RoleTool {
|
||||
return model.Response{Content: a.answer[mark]}, nil
|
||||
}
|
||||
return model.Response{Content: `{"tool_calls":[{"name":"` + a.tool[mark] + `","args":{}}]}`}, nil
|
||||
}
|
||||
|
||||
// markOf 找出请求里出现的那一路的标记文本;两路的窗口互不共享(I4),因此最多一命中。
|
||||
func (a *laneAdapter) markOf(req model.Request) string {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
for mark := range a.tool {
|
||||
if strings.Contains(messageText(m), mark) {
|
||||
return mark
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ChatStream 是流式那一半:本用例的循环走非流式路径(未挂 OnStream),因此它只把
|
||||
// 请求委托给 Chat 并原样交出聚合结果 —— 刻意**不**再造第二套脚本,也刻意不发任何
|
||||
// 增量事件,避免「哪个入口真的跑过」变成模糊事实。
|
||||
func (a *laneAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// lastMessageRole 返回末条消息的角色(空窗口时为空串)。
|
||||
func lastMessageRole(msgs []model.Message) model.Role {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return msgs[len(msgs)-1].Role
|
||||
}
|
||||
|
||||
// parallelLaneFixture 是两路交错跑完之后的全部观测面:落盘的每一路、发布的每一帧、
|
||||
// 以及两路各自的最终答复(用来确认哪张图属于哪一路)。
|
||||
type parallelLaneFixture struct {
|
||||
pub *FlowPublisher
|
||||
recorder *fakeRecorder
|
||||
mu sync.Mutex
|
||||
frames []*trace.FlowGraph
|
||||
}
|
||||
|
||||
// run 让两条平行路交错跑完一轮:A 起轮 → A 的工具在飞 → B 起轮 → B 的工具在飞 →
|
||||
// 依次放行。返回时两路的 Run 都已返回。
|
||||
func (f *parallelLaneFixture) run(t *testing.T) {
|
||||
t.Helper()
|
||||
toolA, toolB := newBarrierTool("probe_a"), newBarrierTool("probe_b")
|
||||
ad := &laneAdapter{
|
||||
tool: map[string]string{"标记A": "probe_a", "标记B": "probe_b"},
|
||||
answer: map[string]string{"标记A": "答复A", "标记B": "答复B"},
|
||||
}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t, toolA, toolB)),
|
||||
MaxRounds: 8,
|
||||
Flow: f.pub,
|
||||
Tracer: trace.New(nil),
|
||||
})
|
||||
|
||||
ctxA := WithParallelLeg(WithTurnID(context.Background(), "turn-a"), "turn-a")
|
||||
ctxB := WithParallelLeg(WithTurnID(context.Background(), "turn-b"), "turn-b")
|
||||
var wg sync.WaitGroup
|
||||
var resA, resB *Result
|
||||
var errA, errB error
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resA, errA = l.Run(ctxA, "web", []model.Message{model.TextMessage(model.RoleUser, "标记A:请跑一次工具")})
|
||||
}()
|
||||
// A 的工具确实在飞之后才起 B —— 这才叫「同一会话两路并行」,也正是缺陷现场。
|
||||
awaitBarrier(t, toolA.arrived, "A 的工具执行点")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resB, errB = l.Run(ctxB, "web", []model.Message{model.TextMessage(model.RoleUser, "标记B:请跑一次工具")})
|
||||
}()
|
||||
awaitBarrier(t, toolB.arrived, "B 的工具执行点")
|
||||
close(toolA.gate)
|
||||
close(toolB.gate)
|
||||
wg.Wait()
|
||||
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("两路 Run 报错:A=%v B=%v", errA, errB)
|
||||
}
|
||||
if resA.Text != "答复A" || resB.Text != "答复B" {
|
||||
t.Fatalf("两路答复 = %q / %q,want 答复A / 答复B(窗口串了)", resA.Text, resB.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// awaitBarrier 等一个闸门信号,超时即让测试失败(避免缺陷把它挂死成超时)。
|
||||
func awaitBarrier(t *testing.T, ch <-chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("等待 %s 超时", what)
|
||||
}
|
||||
}
|
||||
|
||||
// newParallelLaneFixture 装一台接了假落盘出口的发布器(真 bus 不在本用例范围内)。
|
||||
func newParallelLaneFixture(t *testing.T) *parallelLaneFixture {
|
||||
t.Helper()
|
||||
f := ¶llelLaneFixture{recorder: &fakeRecorder{}}
|
||||
f.pub = NewFlowPublisher(func(subject string, payload any) {
|
||||
if subject != graphUpdateSubject {
|
||||
return
|
||||
}
|
||||
g, ok := payload.(*trace.FlowGraph)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.frames = append(f.frames, g)
|
||||
f.mu.Unlock()
|
||||
}, func(string) bool { return true }, WithRoundRecorder(f.recorder))
|
||||
return f
|
||||
}
|
||||
|
||||
// recorded 返回落盘出口收到的每一路图(副本,调用方无需持锁)。
|
||||
func (f *parallelLaneFixture) recorded() []*trace.FlowGraph {
|
||||
return recordedRounds(f.recorder)
|
||||
}
|
||||
|
||||
// recordedRounds 返回假落盘出口收到的每一路图(副本,调用方无需持锁)。
|
||||
func recordedRounds(rec *fakeRecorder) []*trace.FlowGraph {
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
return append([]*trace.FlowGraph(nil), rec.graphs...)
|
||||
}
|
||||
|
||||
// publishedFrames 返回发布过的每一帧(副本)。
|
||||
func (f *parallelLaneFixture) publishedFrames() []*trace.FlowGraph {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]*trace.FlowGraph(nil), f.frames...)
|
||||
}
|
||||
|
||||
// TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther 是本切片的核心用例:
|
||||
// 两路并行 ⇒ 两桶互不覆盖、各自落一条、任何一帧都不含对方节点。
|
||||
func TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther(t *testing.T) {
|
||||
f := newParallelLaneFixture(t)
|
||||
f.run(t)
|
||||
|
||||
recorded := f.recorded()
|
||||
if len(recorded) != 2 {
|
||||
t.Fatalf("落盘 %d 条,want 2(每路一条):%s", len(recorded), describeGraphs(recorded))
|
||||
}
|
||||
if recorded[0].ID == recorded[1].ID {
|
||||
t.Fatalf("两路撞同一个 turn_id %q ⇒ flow_graphs 的 (session_id, turn_id) 会把其中一路整行顶掉:%s",
|
||||
recorded[0].ID, describeGraphs(recorded))
|
||||
}
|
||||
graphA := graphWithInput(recorded, "标记A")
|
||||
graphB := graphWithInput(recorded, "标记B")
|
||||
if graphA == nil || graphB == nil {
|
||||
t.Fatalf("两路没有各自的图(A=%v B=%v):%s", graphA != nil, graphB != nil, describeGraphs(recorded))
|
||||
}
|
||||
assertLaneIsolated(t, "A", graphA, "probe_a", "probe_b")
|
||||
assertLaneIsolated(t, "B", graphB, "probe_b", "probe_a")
|
||||
|
||||
for i, frame := range f.publishedFrames() {
|
||||
if hasNode(frame, "probe_a") && hasNode(frame, "probe_b") {
|
||||
t.Fatalf("第 %d 帧同时含两条路的工具节点(B 的节点长在 A 的图上):%s", i, describeNodes(frame))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertLaneIsolated 断言某一路的图里有自己的工具节点、且**没有**对方的工具节点。
|
||||
func assertLaneIsolated(t *testing.T, lane string, g *trace.FlowGraph, own, other string) {
|
||||
t.Helper()
|
||||
if !hasNode(g, own) {
|
||||
t.Fatalf("%s 路的图里没有自己的工具节点 %q:%s", lane, own, describeNodes(g))
|
||||
}
|
||||
if hasNode(g, other) {
|
||||
t.Fatalf("%s 路的图里出现了另一路的工具节点 %q:%s", lane, other, describeNodes(g))
|
||||
}
|
||||
if g.SessionID != "web" {
|
||||
t.Fatalf("%s 路图的 sessionId = %q,want web(路不能改会话归属)", lane, g.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// graphWithInput 找出输入节点标签含 mark 的那张图(两路各自被用户文本播种)。
|
||||
func graphWithInput(graphs []*trace.FlowGraph, mark string) *trace.FlowGraph {
|
||||
for _, g := range graphs {
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == "input" && strings.Contains(n.Label, mark) {
|
||||
return g
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasNode 报告图里有没有标签等于(或前缀为)name 的工具节点。
|
||||
func hasNode(g *trace.FlowGraph, name string) bool {
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == "tool" && strings.Contains(n.Label, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// describeGraphs / describeNodes 只打印**结构与标签**,供失败信息定位(测试里没有
|
||||
// 敏感内容,但仍只打标识)。
|
||||
func describeGraphs(graphs []*trace.FlowGraph) string {
|
||||
var b strings.Builder
|
||||
for _, g := range graphs {
|
||||
b.WriteString("\n id=" + g.ID + " session=" + g.SessionID)
|
||||
b.WriteString(" nodes=" + describeNodes(g))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func describeNodes(g *trace.FlowGraph) string {
|
||||
parts := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
parts = append(parts, n.Kind+":"+n.Label)
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
@@ -83,9 +83,14 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// 分支 Run(子代理)把其 span 并入父会话的实时
|
||||
// 图;只有所属的 Run 开始与结束轮次。没有它,子代理
|
||||
// 会重置前端唯一的 flowGraph,并让父级自身的节点出现断档。
|
||||
//
|
||||
// 归属取**桶**(`{会话}[#{路}]`,见 FlowOwnerKey):真并行时本 Run 属于某一条路,
|
||||
// 两路因此各起各的图、各收各的图 —— 后起的那一路绝不重置前一路(design/15
|
||||
// §2.5.10-1)。单路时桶逐字等于 sessionID,行为与引入本切片之前完全一致。
|
||||
if _, isBranch := flowBranch(ctx); l.flow != nil && !isBranch {
|
||||
l.flow.StartRound(sessionID, lastUserText(msgs))
|
||||
defer func() { l.flow.EndRound(sessionID, res.Text) }()
|
||||
owner := flowOwner(ctx, sessionID)
|
||||
l.flow.StartRound(owner, lastUserText(msgs))
|
||||
defer func() { l.flow.EndRound(owner, res.Text) }()
|
||||
}
|
||||
return l.driveTurn(ctx, sessionID, msgs, startStep, res)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (l *Loop) steerAtBoundary(ctx context.Context, sessionID string, step int,
|
||||
// 链路图如实标记「本轮输入被中途追加过」(design/15 §5)。子代理 Run 不拥有
|
||||
// 自己的图(它的 span 并入派生它的会话),因此分支 Run 不打这个标记。
|
||||
if _, isBranch := flowBranch(ctx); !isBranch {
|
||||
l.flow.MarkInputSteered(sessionID)
|
||||
l.flow.MarkInputSteered(flowOwner(ctx, sessionID))
|
||||
}
|
||||
l.log.Info("steer injected", "session", sessionID, "step", step, "messages", len(msgs))
|
||||
return append(steered, msgs...)
|
||||
|
||||
@@ -139,6 +139,7 @@ func (s *Scheduler) Spawn(ctx context.Context, spec AgentSpec) (*SubAgent, error
|
||||
Mode: spec.Mode,
|
||||
AttachInfo: spec.AttachInfo,
|
||||
ParentSession: spec.ParentSession,
|
||||
FlowOwner: spec.FlowOwner,
|
||||
Data: spec.Data,
|
||||
ToolWhitelist: cloneStrings(spec.ToolWhitelist),
|
||||
Context: taskContext(spec),
|
||||
@@ -295,9 +296,10 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
// 子代理不得把内心独白泄漏进调用方的聊天流
|
||||
// (design/05 §8: 少说话、多做事; design/04 §2: 不污染前台):静音抑制
|
||||
// UI 流/容量广播,而进度仍经总线与泳道到达前端。flow 分支把本次 Run 发射的
|
||||
// 每个 span 都归属到派发会话的图,而不是另开第二张图。
|
||||
// 每个 span 都归属到派发**那一路**的图(`{会话}[#{路}]`,见 SubAgent.flowOwner),
|
||||
// 而不是另开第二张图、也不是落进会话主干那张。
|
||||
runCtx = loop.WithMutedBroadcast(runCtx)
|
||||
runCtx = loop.WithFlowBranch(runCtx, sub.ParentSession)
|
||||
runCtx = loop.WithFlowBranch(runCtx, sub.flowOwner())
|
||||
// 被派发的目标角色决定本次运行每一步的提示词与记忆作用域(design/22 §8-2)。
|
||||
// 留空则保持继承来的 env:既有行为,即子代理按派发回合的角色运行。
|
||||
runCtx = withPersonaEnv(runCtx, sub.PersonaID)
|
||||
@@ -370,18 +372,18 @@ func (s *Scheduler) subCtx(parent context.Context, sub *SubAgent) (context.Conte
|
||||
}
|
||||
|
||||
// subSpan 在配置了 tracer 时开启一个子代理 span,把它串成 ctx 中已有 span 的子节点。
|
||||
// 子代理被归属到某个 flow 会话(AgentSpec.ParentSession)时,该 span 还会作为子代理
|
||||
// 泳道根折进那个会话的实时图,使分支渲染在派发它的会话图内,而不是自成一张图
|
||||
// (design/19 §2)。它返回携带该 span 的 ctx 与 span 自身(tracer 为 nil 时 span 为
|
||||
// nil)。
|
||||
// 子代理被归属到某张 flow 图(`SubAgent.flowOwner`,即 `{会话}[#{路}]`)时,该 span
|
||||
// 还会作为子代理泳道根折进那张图,使分支渲染在派发它的**那一路**图内,而不是自成
|
||||
// 一张图、也不会错落进另一路的图(design/19 §2)。它返回携带该 span 的 ctx 与 span
|
||||
// 自身(tracer 为 nil 时 span 为 nil)。
|
||||
func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context, *trace.Span) {
|
||||
if s.tracer == nil {
|
||||
return ctx, nil
|
||||
}
|
||||
sctx, span := s.tracer.StartSpan(ctx, "subagent."+sub.ID, trace.SpanKindSubagent)
|
||||
if sess := sub.ParentSession; sess != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", sess)
|
||||
s.flow.SpanStarted(sess, span)
|
||||
if owner := sub.flowOwner(); owner != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", owner)
|
||||
s.flow.SpanStarted(owner, span)
|
||||
}
|
||||
// 目标角色随分支节点传播,使流程图面板展示子代理以哪个角色作答
|
||||
// (design/22 §8-2 可观测性),而不只是子代理 id。
|
||||
@@ -427,13 +429,16 @@ func (s *Scheduler) spanErr(sub *SubAgent, span *trace.Span, err error) {
|
||||
s.foldSpanFinish(sub, span)
|
||||
}
|
||||
|
||||
// foldSpanFinish 把一个刚关闭的子代理 span 折进其父会话的流程图,把泳道根翻转为终态。
|
||||
// 该 span 已把父会话带在 "session" 属性上,因此发布器知道哪张图拥有它。
|
||||
// foldSpanFinish 把一个刚关闭的子代理 span 折进其所属的流程图,把泳道根翻转为终态。
|
||||
// 该 span 已把归属桶带在 "session" 属性上,因此发布器知道哪张图拥有它
|
||||
// (真并行时就是派发它的那一路的图,见 SubAgent.flowOwner)。
|
||||
func (s *Scheduler) foldSpanFinish(sub *SubAgent, span *trace.Span) {
|
||||
if span == nil || s.flow == nil || sub.ParentSession == "" {
|
||||
if span == nil || s.flow == nil || sub == nil {
|
||||
return
|
||||
}
|
||||
s.flow.SpanFinished(sub.ParentSession, span)
|
||||
if owner := sub.flowOwner(); owner != "" {
|
||||
s.flow.SpanFinished(owner, span)
|
||||
}
|
||||
}
|
||||
|
||||
// publish 经总线广播一条进度事件。未配置 comm 探针时是空操作。发布使用后台上下文,
|
||||
|
||||
@@ -22,7 +22,8 @@ const (
|
||||
// 「派发标记」:与泳道根并列而不是它的父级 —— 若把它当作子代理 span 的父级,
|
||||
// 泳道链会多出一节(图构建器把非 subagent 祖先一路穿透解析泳道),既有泳道
|
||||
// 契约就被改写了。span 只带非敏感属性(被派发的子代理 id、目标角色 id),
|
||||
// 声明了归属会话时同时发布一个「运行中」占位节点。未配置 tracer 时返回 nil。
|
||||
// 声明了归属时同时发布一个「运行中」占位节点(真并行时落进派发它的那一路的图)。
|
||||
// 未配置 tracer 时返回 nil。
|
||||
func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
|
||||
if s.tracer == nil || sub == nil {
|
||||
return nil
|
||||
@@ -34,15 +35,15 @@ func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
|
||||
if sub.PersonaID != "" {
|
||||
_ = span.SetProp("persona", sub.PersonaID)
|
||||
}
|
||||
if sess := sub.ParentSession; sess != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", sess)
|
||||
s.flow.SpanStarted(sess, span)
|
||||
if owner := sub.flowOwner(); owner != "" && s.flow != nil {
|
||||
_ = span.SetProp("session", owner)
|
||||
s.flow.SpanStarted(owner, span)
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
// finishOrchSpan 收尾一次派发:记录耗时(失败另记原因)与一条脱敏事件,
|
||||
// 失败时终态为 error,然后把已关闭的 span 折回所属会话的图。
|
||||
// 失败时终态为 error,然后把已关闭的 span 折回所属那一路的图。
|
||||
func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
@@ -58,8 +59,10 @@ func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
|
||||
status = trace.SpanStatusError
|
||||
}
|
||||
span.Finish(status)
|
||||
if s.flow == nil || sub == nil || sub.ParentSession == "" {
|
||||
if s.flow == nil || sub == nil {
|
||||
return
|
||||
}
|
||||
s.flow.SpanFinished(sub.ParentSession, span)
|
||||
if owner := sub.flowOwner(); owner != "" {
|
||||
s.flow.SpanFinished(owner, span)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住**真并行路里派发的子代理落进那一路的图**(切片 FV2;design/15 §2.5.10-1)。
|
||||
//
|
||||
// 症状:同一会话真并行时两路各有一张图,而子代理的 span 归属取自 `AgentSpec.ParentSession`
|
||||
// —— 那是**会话**(总线载荷要用它),不含路 ⇒ 子代理的泳道会落进「会话主干那张」,而那张图
|
||||
// 在纯并行轮次里根本不存在(span 被静默丢弃),或者落进另一路。这里断言的是:派发工具从
|
||||
// ctx 取出的**归属桶**(`{会话}#{路}`)一路传到发布器,泳道根只长在**那一路**的图上。
|
||||
|
||||
// laneGraphCapture 按 (会话, 路) 记录发布器发出的每一张图(路的键就是帧上的 `turnId`)。
|
||||
type laneGraphCapture struct {
|
||||
mu sync.Mutex
|
||||
lanes map[string]*trace.FlowGraph
|
||||
}
|
||||
|
||||
func laneKey(session, turnID string) string { return session + "|" + turnID }
|
||||
|
||||
func (c *laneGraphCapture) publish(_ string, payload any) {
|
||||
g, ok := payload.(*trace.FlowGraph)
|
||||
if !ok || g == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.lanes == nil {
|
||||
c.lanes = make(map[string]*trace.FlowGraph)
|
||||
}
|
||||
c.lanes[laneKey(g.SessionID, g.TurnID)] = g
|
||||
}
|
||||
|
||||
// lane 返回某一路最新那张图;不存在时判测试失败(并列出收到过哪些路)。
|
||||
func (c *laneGraphCapture) lane(t *testing.T, session, turnID string) *trace.FlowGraph {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
g := c.lanes[laneKey(session, turnID)]
|
||||
if g == nil {
|
||||
t.Fatalf("没有收到会话 %q 路 %q 的图(收到过的路:%v)", session, turnID, c.keysLocked())
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (c *laneGraphCapture) keysLocked() []string {
|
||||
out := make([]string, 0, len(c.lanes))
|
||||
for k := range c.lanes {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// branchOf 造一个「运行在某一路上」的 ctx(网关经 WithParallelLeg 盖上会话 + 路;
|
||||
// 子代理自身则经 WithFlowBranch 带着父归属桶)。两条通道的 `FlowOwnerFromContext`
|
||||
// 取值同源,因此派发工具的取值路径只有这一条。
|
||||
func branchOf(session, lane string) context.Context {
|
||||
return loop.WithFlowBranch(context.Background(), loop.FlowOwnerKey(session, lane))
|
||||
}
|
||||
|
||||
// TestSpawnToolBindsSubAgentToItsLaneGraph 是核心断言:在一路里派发的子代理,其泳道根
|
||||
// 只出现在**那一路**的图上,且会话主干那张图**不存在**(=没有被静默丢弃或错落他路)。
|
||||
func TestSpawnToolBindsSubAgentToItsLaneGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
capture := &laneGraphCapture{}
|
||||
flow := loop.NewFlowPublisher(capture.publish, nil)
|
||||
tracer := trace.New(nil)
|
||||
|
||||
d, err := tool.New(registry.New())
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
if rerr := d.Register(&echoTool{name: "echo", result: "ok"}); rerr != nil {
|
||||
t.Fatalf("Register: %v", rerr)
|
||||
}
|
||||
s := branchLoop(t, flow, tracer, d, Options{MaxParallel: 2})
|
||||
|
||||
// 这一路已经开跑(网关在调用 Run 之前就发过 turn_begin,图的归属桶因此已存在)。
|
||||
owner := loop.FlowOwnerKey(parentSession, "turn-a")
|
||||
flow.StartRound(owner, "并行的一路")
|
||||
sp := NewSpawnTool(s, WithSpawnWait(5*time.Second))
|
||||
if _, xerr := sp.Execute(branchOf(parentSession, "turn-a"), map[string]any{"goal": "调研一下"}); xerr != nil {
|
||||
t.Fatalf("派发: %v", xerr)
|
||||
}
|
||||
flow.EndRound(owner, "结果如下")
|
||||
|
||||
g := capture.lane(t, parentSession, "turn-a")
|
||||
roots := 0
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == "subagent" {
|
||||
roots++
|
||||
}
|
||||
}
|
||||
if roots != 1 {
|
||||
t.Fatalf("该路的图里子代理泳道根 = %d 个,want 1(nodes=%v)", roots, nodeIDs(g))
|
||||
}
|
||||
if strings.HasSuffix(g.ID, "#turn-a") == false {
|
||||
t.Fatalf("该路的图 id = %q,want 带 `#turn-a` 后缀", g.ID)
|
||||
}
|
||||
// 会话主干那张(无路标识)绝不该被这一路的子代理污染:它要么不存在,要么不含泳道根。
|
||||
capture.mu.Lock()
|
||||
trunk := capture.lanes[laneKey(parentSession, "")]
|
||||
capture.mu.Unlock()
|
||||
if trunk != nil {
|
||||
for _, n := range trunk.Nodes {
|
||||
if n.Kind == "subagent" {
|
||||
t.Fatalf("子代理泳道根长在了会话主干图上(路桶 %v 才是它该在的地方)", capture.keysLocked())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,10 +153,17 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if t.sched == nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, ErrNoScheduler)
|
||||
}
|
||||
specs, err := spawnSpecs(m, loop.FlowOwnerFromContext(ctx), t.timeout)
|
||||
specs, err := spawnSpecs(m, loop.FlowSessionFromContext(ctx), t.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
|
||||
}
|
||||
// flow 归属桶(`{会话}[#{路}]`)另取一次:ParentSession 必须是**真会话 id**
|
||||
// (后台批次的 `subagent_result` 帧按它归属到父会话),而图归属要精确到**哪一路**,
|
||||
// 否则真并行时两路子代理会混进同一张图(design/15 §2.5.10-1)。
|
||||
owner := loop.FlowOwnerFromContext(ctx)
|
||||
for i := range specs {
|
||||
specs[i].FlowOwner = owner
|
||||
}
|
||||
// 在任何东西启动之前就拒绝未知的目标角色:批次必须是全有或全无,否则已经派发出的
|
||||
// 子代理会继续运行,而它们的结果永远不上报给模型(design/22 §8-2: 未知角色明确报错,
|
||||
// 不静默回落到默认角色).
|
||||
|
||||
@@ -58,9 +58,17 @@ type AgentSpec struct {
|
||||
AttachInfo string
|
||||
// ParentSession 是拥有该分支的会话,其实时流程图将承载子代理的 span 及其内部
|
||||
// 步骤,使它们渲染成派发会话图的一条泳道,而不是自成一张图(design/19 §2)。
|
||||
// 派发工具从 loop.FlowOwnerFromContext 取得它。留空则关闭流图归属;它绝不影响
|
||||
// 子代理在哪个会话里运行,因此其 checkpoint、记忆与流隔离都不变。
|
||||
// 派发工具从 loop.FlowSessionFromContext 取得它(**不含路**:后台批次的
|
||||
// `subagent_result` 帧按它归属到父会话,因此它必须是真会话 id)。
|
||||
// 留空则关闭流图归属;它绝不影响子代理在哪个会话里运行,因此其 checkpoint、
|
||||
// 记忆与流隔离都不变。
|
||||
ParentSession string
|
||||
// FlowOwner 是子代理 span 落进**哪一张** flow 图的归属桶
|
||||
// (`{会话}[#{路}]`,见 loop.FlowOwnerFromContext)。真并行时同一会话有多条路,
|
||||
// 派发它的那一路有自己的一张图,子代理的泳道必须落进那一路 —— 否则两路的
|
||||
// 子代理节点会互相长在对方的图上(design/15 §2.5.10-1)。派发工具填它;
|
||||
// 留空时回落到 ParentSession(会话主干那张,即单路与旧调用方的既有行为)。
|
||||
FlowOwner string
|
||||
// Data 承载供调用方使用的可选结构化载荷。
|
||||
Data any
|
||||
// ToolWhitelist 限定该子代理可执行的工具集合(design/05 §7 裁决 02.4.1):
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
)
|
||||
|
||||
// SubAgent 是单个已派发的子代理实例。身份字段(ID、Goal、RoleID、PersonaID、Mode、
|
||||
// AttachInfo、ParentSession、Data、ToolWhitelist、Context、WorkingMemory)在 Spawn 时
|
||||
// 一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由子代理的后台
|
||||
// goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们是安全的。当且
|
||||
// 仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后返回,Wait 在指名的
|
||||
// id 全部终结后返回。
|
||||
// AttachInfo、ParentSession、FlowOwner、Data、ToolWhitelist、Context、WorkingMemory)
|
||||
// 在 Spawn 时一次设定,此后永不改动。生命周期字段(Status、Summary、Result、Err)由
|
||||
// 子代理的后台 goroutine 写入,并经下方同步访问器读取,因此在子代理结束前读取它们
|
||||
// 是安全的。当且仅当 Done 报告 true 时子代理处于终结态;Join 在全部子代理终结后
|
||||
// 返回,Wait 在指名的 id 全部终结后返回。
|
||||
//
|
||||
// SubAgent 由 (*Scheduler).Spawn 创建;不应直接构造。
|
||||
type SubAgent struct {
|
||||
@@ -35,6 +35,12 @@ type SubAgent struct {
|
||||
AttachInfo string
|
||||
// ParentSession 是拥有本子代理分支的流程图所属会话(该分支不被可视化时为空)。
|
||||
ParentSession string
|
||||
// FlowOwner 是本子代理 span 落进**哪一张** flow 图的归属桶
|
||||
// (`{会话}[#{路}]`,见 loop.FlowOwnerFromContext):真并行时派发它的那一路
|
||||
// 有自己的图,本子代理必须落进那一路,而不是会话主干那张 —— 否则两路的
|
||||
// 子代理节点会互相长在对方的图上(design/15 §2.5.10-1)。留空时回落到
|
||||
// ParentSession(=会话主干那张,单路/旧调用方的既有行为)。
|
||||
FlowOwner string
|
||||
// Data 是派发时提供的可选结构化载荷。
|
||||
Data any
|
||||
// ToolWhitelist 是该子代理的工具白名单(design/05 §7 裁决 02.4.1):
|
||||
@@ -117,6 +123,20 @@ func (s *SubAgent) FinishedAt() time.Time {
|
||||
// Done 报告子代理是否已进入终结态。
|
||||
func (s *SubAgent) Done() bool { return s.Status().Terminal() }
|
||||
|
||||
// flowOwner 返回本子代理 span 该落进哪一张 flow 图的归属桶
|
||||
// (`{会话}[#{路}]`):显式声明了 FlowOwner 时用它(真并行路派发的子代理因此落进
|
||||
// **那一路**的图),否则回落到 ParentSession(会话主干那张图,即单路与旧调用方
|
||||
// 的既有行为)。两者都为空时返回空串 —— 调用方据此抑制发布(不被可视化的分支)。
|
||||
func (s *SubAgent) flowOwner() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
if s.FlowOwner != "" {
|
||||
return s.FlowOwner
|
||||
}
|
||||
return s.ParentSession
|
||||
}
|
||||
|
||||
// waitDone 返回子代理进入终结态时被关闭的 channel。它是 (*Scheduler).Wait 按 id 阻塞
|
||||
// 时等待的对象,无需轮询。
|
||||
func (s *SubAgent) waitDone() <-chan struct{} { return s.finishedCh }
|
||||
|
||||
@@ -61,12 +61,22 @@ type FlowEdge struct {
|
||||
|
||||
// FlowGraph is a live, single-round flow graph published as ws.graph_update.
|
||||
type FlowGraph struct {
|
||||
ID string `json:"id"`
|
||||
Nodes []FlowNode `json:"nodes"`
|
||||
Edges []FlowEdge `json:"edges"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Nodes []FlowNode `json:"nodes"`
|
||||
Edges []FlowEdge `json:"edges"`
|
||||
// SessionID 是拥有这张图的会话(帧壳与落盘行的归属坐标)。
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
// TurnID 是这张图所属**路**的权威回合标识(design/15 §2.5.9):真并行时
|
||||
// 同一会话有多条路,每条路各有自己的图与本键。**缺席**语义:这条路没有
|
||||
// 权威标识(单路的历史帧、未接编排器的调用方、旧后端)——客户端据此把图
|
||||
// 归到会话那一张(兼容口径),绝不臆造一个 id。
|
||||
TurnID string `json:"turnId,omitempty"`
|
||||
// Round 是这张图所属的**会话轮次号**(每会话单调递增;同一轮的各条路共享它)。
|
||||
// 客户端据此只保留最新一轮的泳道,避免旧轮的路留在页面上。
|
||||
// **缺席**(0)语义:旧后端/未设置 —— 客户端按「每次帧都算同一轮」的保守口径处理。
|
||||
Round int `json:"round,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
}
|
||||
|
||||
// NodeSpec is the input to RunGraph.Upsert: the full description of a node plus
|
||||
@@ -87,8 +97,16 @@ type NodeSpec struct {
|
||||
// structure: it knows nothing about the bus, sessions or the loop, so it is
|
||||
// unit-testable in isolation. A caller serializes access (see loop.FlowPublisher).
|
||||
type RunGraph struct {
|
||||
session string
|
||||
round int
|
||||
session string
|
||||
round int
|
||||
// seq 是本图的**会话级建图序号**(调用方每次建图递增、永不复用)。图 id 的第二
|
||||
// 分量取它而不是轮次号:真并行时同一轮里的第二条路必须也拿到全新的 id,否则两路
|
||||
// 撞同一个 `flow_graphs.turn_id`(唯一键),后收尾的那一路把前一路整行顶掉。
|
||||
// 未经 SetSeq 建图的调用方(图构建器单测)保留 `{会话}-{轮次}` 的老形状。
|
||||
seq int
|
||||
// turnID 是本图所属**路**的权威回合标识(design/15 §2.5.9)。空串表示这条路
|
||||
// 没有权威标识(单路 / 未接编排器 / 旧调用方),此时图上该键缺席。
|
||||
turnID string
|
||||
nodes map[string]*FlowNode
|
||||
order []string
|
||||
edges map[string]bool
|
||||
@@ -118,7 +136,8 @@ func NewRunGraph(session string) *RunGraph {
|
||||
}
|
||||
|
||||
// Start marks the round began at now and resets the node/edge sets so a reused
|
||||
// graph starts fresh for a new round.
|
||||
// graph starts fresh for a new round. 路口标识不在重置之列:它是**本次建图**的
|
||||
// 身份,由 SetTurnID 在 Start 之后立刻盖上(见 loop.FlowPublisher)。
|
||||
func (g *RunGraph) Start(round int, now time.Time) {
|
||||
g.round = round
|
||||
g.started = now
|
||||
@@ -131,6 +150,15 @@ func (g *RunGraph) Start(round int, now time.Time) {
|
||||
g.laneLast = make(map[string]string)
|
||||
}
|
||||
|
||||
// SetTurnID 盖上这张图所属**路**的权威回合标识(design/15 §2.5.9)。空串是
|
||||
// 合法入参(单路 / 未接编排器),此时快照里该键缺席(omitempty),客户端回落
|
||||
// 到「按会话归一张图」的兼容口径。
|
||||
func (g *RunGraph) SetTurnID(turnID string) { g.turnID = turnID }
|
||||
|
||||
// SetSeq 盖上这张图的**会话级建图序号**(见 RunGraph.seq)。未设置(0)时图 id
|
||||
// 退回 `{会话}-{轮次}` 的老形状,因此只调 Start 的既有调用方/单测逐字不变。
|
||||
func (g *RunGraph) SetSeq(seq int) { g.seq = seq }
|
||||
|
||||
// SeedInput creates the "input" node for the round. It returns the node id so a
|
||||
// caller can reference it. The label is redacted (design/19 §3), so a message that
|
||||
// happens to carry a credential cannot publish one.
|
||||
@@ -160,22 +188,48 @@ func (g *RunGraph) SeedOutput(text string) string {
|
||||
return "output"
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the graph safe to encode and publish.
|
||||
// laneIDSep 是图 id 里「会话-轮次」与「路标识」之间的分隔符。它与 `loop` 的
|
||||
// flow 归属串(`{会话}#{路}`)以及 design/15 §2.5.9 的 checkpoint 归属键
|
||||
// (`{sessionID}#{turnID}`)取同一个字符:**一条路**在流程图、checkpoint 与
|
||||
// 落盘行三处因此是同一个拼接口径(loop 侧的同名常量见 loop/flow_branch.go)。
|
||||
const laneIDSep = "#"
|
||||
|
||||
// Snapshot returns a copy of the graph safe to encode and publish. 图 id 是
|
||||
// `{会话}-{轮次}`;这张图属于一条**路**时追加 `#{路标识}` —— 真并行下同一会话
|
||||
// 同一轮的两路必须各有各的 id,否则落盘(`flow_graphs` 的 `(session_id, turn_id)`
|
||||
// 唯一键)会把其中一路整行顶掉、客户端也分不出两张图(design/15 §2.5.10-1)。
|
||||
func (g *RunGraph) Snapshot() *FlowGraph {
|
||||
nodes := make([]FlowNode, 0, len(g.order))
|
||||
for _, id := range g.order {
|
||||
nodes = append(nodes, *g.nodes[id])
|
||||
}
|
||||
return &FlowGraph{
|
||||
ID: fmt.Sprintf("%s-%d", g.session, g.round),
|
||||
ID: g.graphID(),
|
||||
Nodes: nodes,
|
||||
Edges: append([]FlowEdge(nil), g.edgeOrder...),
|
||||
SessionID: g.session,
|
||||
TurnID: g.turnID,
|
||||
Round: g.round,
|
||||
StartedAt: g.started,
|
||||
EndedAt: g.ended,
|
||||
}
|
||||
}
|
||||
|
||||
// graphID 拼出这张图的 id:`{会话}-{序号}[#{路标识}]`。序号取会话级建图序号
|
||||
// (未设置时退回轮次号,见 RunGraph.seq),因此**同一轮里的每一条路也各有各的 id**;
|
||||
// 路标识只在「这条路有权威标识」时追加。
|
||||
func (g *RunGraph) graphID() string {
|
||||
n := g.seq
|
||||
if n == 0 {
|
||||
n = g.round
|
||||
}
|
||||
base := fmt.Sprintf("%s-%d", g.session, n)
|
||||
if g.turnID == "" {
|
||||
return base
|
||||
}
|
||||
return base + laneIDSep + g.turnID
|
||||
}
|
||||
|
||||
// Clone 深拷贝一份图,供**锁外**的读者使用(契约 v0.71 的
|
||||
// `GET /api/v1/admin/flow/graph` 就近取材于 `loop.FlowPublisher.LastGraph`)。
|
||||
//
|
||||
@@ -195,6 +249,8 @@ func (g *FlowGraph) Clone() *FlowGraph {
|
||||
Nodes: make([]FlowNode, 0, len(g.Nodes)),
|
||||
Edges: append([]FlowEdge(nil), g.Edges...),
|
||||
SessionID: g.SessionID,
|
||||
TurnID: g.TurnID,
|
||||
Round: g.Round,
|
||||
StartedAt: g.StartedAt,
|
||||
EndedAt: g.EndedAt,
|
||||
}
|
||||
|
||||
@@ -42,15 +42,27 @@ type flowTurnDTO struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// flowLaneDTO 是「最近一轮里的一条路」(契约 v0.77 的 `lanes[]`):`turnId` 是**那一路**
|
||||
// 的权威回合标识(`graph.turnId`,design/15 §2.5.9),`graph` 是那一路的图快照
|
||||
// (与 `graph_update` 帧的 data / 本响应的 `graph` 逐字同形)。
|
||||
//
|
||||
// 真并行时同一会话同一轮有多条路,`graph` 只给出最新的一条(向后兼容:老客户端读它
|
||||
// 照旧可用),`lanes[]` 才是全貌 —— 并行子代理与并行路各自的图因此不再互相顶掉。
|
||||
type flowLaneDTO struct {
|
||||
TurnID string `json:"turnId"`
|
||||
Graph any `json:"graph"`
|
||||
}
|
||||
|
||||
// adminFlowGraphResponse 是 GET /api/v1/admin/flow/graph 的响应体
|
||||
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,纯增量)。
|
||||
// (v0.71 起;**v0.73 起新增 `turnId`/`source`/`turns` 三键**,**v0.77 起新增 `lanes`
|
||||
// 一键**,均为纯增量)。
|
||||
//
|
||||
// Graph 为 nil 表示「该轮确实没有图」——这是一个**诚实的空态**,不是错误:
|
||||
// 客户端必须据此渲染空态,绝不允许补画任何节点或假图(§4.11(b))。
|
||||
// 该键**恒在**(`null` 而不是缺席),使客户端不必区分「没有这个键」与
|
||||
// 「这个键是空」。
|
||||
//
|
||||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。
|
||||
// Turns **恒为非 nil 切片**(没有轮次时是 `[]`),使客户端不必判空。Lanes 同此口径。
|
||||
type adminFlowGraphResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
// TurnID 是**实际返回的那一轮**的标识(`graph.id`);没有图时为空串。
|
||||
@@ -61,6 +73,11 @@ type adminFlowGraphResponse struct {
|
||||
// (契约 §3.4.5 / §4.11(a))。字段以 `any` 承载,因为「没有图」(nil ⇒
|
||||
// 线上 JSON `null`)与「有图」的区分就是本字段的语义。
|
||||
Graph any `json:"graph"`
|
||||
// Lanes 是**最近一轮每一路**的图(契约 v0.77,纯增量;真并行才有意义的全貌)。
|
||||
// 它取内存里的实时图(`FlowGraphSource.LatestLanes`),因此与 `?turnId=` 无关 ——
|
||||
// 翻历史轮时 `lanes` 仍报「当前这一轮的各路」,而 `graph` 才是你指定的那一轮。
|
||||
// 图源未接线或该会话还没有图时是空数组(恒非 nil)。
|
||||
Lanes []flowLaneDTO `json:"lanes"`
|
||||
// Turns 是该会话**已落盘轮次**的摘要,最新在前(契约 v0.73)。
|
||||
Turns []flowTurnDTO `json:"turns"`
|
||||
}
|
||||
@@ -120,10 +137,31 @@ func (s *Server) adminFlowGraph(w http.ResponseWriter, r *http.Request) {
|
||||
TurnID: resolvedTurn,
|
||||
Source: source,
|
||||
Graph: graphAny,
|
||||
Lanes: s.flowLanes(sessionID),
|
||||
Turns: turns,
|
||||
})
|
||||
}
|
||||
|
||||
// flowLanes 取**最近一轮每一路**的图(契约 v0.77 的 `lanes[]`):真并行时同一会话
|
||||
// 同一轮有多条路,只回 `graph` 一棵会把其余路从面板上抹掉(design/15 §2.5.10-1)。
|
||||
//
|
||||
// 它恒返回非 nil 切片(没有路时是 `[]`),使客户端不必判空。图源未接线(`s.flow` 为
|
||||
// nil)时回空切片而不是错误:那时端点本就走「只有落盘」的形态(v0.73 口径),
|
||||
// `lanes` 缺席语义 = 「没有实时路」。
|
||||
func (s *Server) flowLanes(sessionID string) []flowLaneDTO {
|
||||
out := make([]flowLaneDTO, 0)
|
||||
if s.flow == nil {
|
||||
return out
|
||||
}
|
||||
for _, g := range s.flow.LatestLanes(sessionID) {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, flowLaneDTO{TurnID: g.TurnID, Graph: g})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveFlowGraph 解析「这一轮」的图:**先内存、miss 才落盘**(契约 v0.73 §4.11(b))。
|
||||
//
|
||||
// 判定顺序(三条都必要):
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package gateway
|
||||
|
||||
// 本文件钉住契约 v0.77 的**按路**形状(切片 FV2,修 design/15 §2.5.10-1 登记的那条
|
||||
// 「graph_update 互相顶掉」):
|
||||
//
|
||||
// - 帧 / 快照的图上多了 `turnId`(这张图属于哪一路的权威回合标识)与 `round`
|
||||
// (会话轮次号),两者都是**纯增量**(老客户端忽略未知键仍可用);
|
||||
// - `GET /api/v1/admin/flow/graph` 多了 `lanes[]`:真并行时同一会话同一轮有多条路,
|
||||
// 只回 `graph` 一棵会把其余路从面板上抹掉。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// startLane 让发布器开始**一条路**的一轮(起轮 + 一个在飞的工具 span),并把归属桶与
|
||||
// 该 span 交回调用方,供稍后收尾用。两条路必须**交错**起轮(B 在 A 在飞时起)——
|
||||
// 那才是真并行的现场:轮次号正是在「上一轮的路全部收尾」之后才推进的。
|
||||
func (e *flowTestEnv) startLane(t *testing.T, session, lane, input string) (string, *trace.Span) {
|
||||
t.Helper()
|
||||
owner := loop.FlowOwnerKey(session, lane)
|
||||
e.flow.StartRound(owner, input)
|
||||
sp := &trace.Span{ID: "span-" + lane, Name: "tool." + lane, Kind: trace.SpanKindTool, StartedAt: time.Now()}
|
||||
e.flow.SpanStarted(owner, sp)
|
||||
sp.Finish(trace.SpanStatusOK)
|
||||
e.flow.SpanFinished(owner, sp)
|
||||
return owner, sp
|
||||
}
|
||||
|
||||
// finishLane 收尾一条路(答复播种 output 节点并发布最终帧)。
|
||||
func (e *flowTestEnv) finishLane(t *testing.T, owner, output string) {
|
||||
t.Helper()
|
||||
e.flow.EndRound(owner, output)
|
||||
}
|
||||
|
||||
// flowLanesData 解码响应体里的 `lanes[]`(契约 v0.77)。缺键时回 nil(调用方据此判红)。
|
||||
func flowLanesData(t *testing.T, body []byte) []map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
env := decodeBody(t, body)
|
||||
var data struct {
|
||||
Lanes []map[string]json.RawMessage `json:"lanes"`
|
||||
}
|
||||
if err := json.Unmarshal(env["data"], &data); err != nil {
|
||||
t.Fatalf("decode lanes %s: %v", body, err)
|
||||
}
|
||||
return data.Lanes
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound 是本次快照侧的核心断言:
|
||||
// 真并行两路各自收轮之后打开页面,`lanes[]` 必须**每一路一条**、彼此不缺,
|
||||
// 且 `graph`(向后兼容的那一棵)是其中最新的一路。
|
||||
func TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
ownerA, _ := env.startLane(t, "web", "turn-a", "标记A")
|
||||
ownerB, _ := env.startLane(t, "web", "turn-b", "标记B")
|
||||
env.finishLane(t, ownerA, "答复A")
|
||||
env.finishLane(t, ownerB, "答复B")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
_, graph, present := flowGraphData(t, body)
|
||||
if !present || graph == nil {
|
||||
t.Fatalf("`graph` 必须恒在且非空(两路都收轮了):%s", body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if len(lanes) != 2 {
|
||||
t.Fatalf("lanes 条数 = %d,want 2(同一轮的两路各一条):%s", len(lanes), body)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, lane := range lanes {
|
||||
var (
|
||||
turnID string
|
||||
gid string
|
||||
nodes []struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
)
|
||||
_ = json.Unmarshal(lane["turnId"], &turnID)
|
||||
var inner map[string]json.RawMessage
|
||||
if err := json.Unmarshal(lane["graph"], &inner); err != nil {
|
||||
t.Fatalf("lanes[].graph 不是图对象:%v (%s)", err, body)
|
||||
}
|
||||
_ = json.Unmarshal(inner["id"], &gid)
|
||||
if err := json.Unmarshal(inner["nodes"], &nodes); err != nil {
|
||||
t.Fatalf("lanes[].graph.nodes: %v", err)
|
||||
}
|
||||
seen[turnID] = gid
|
||||
for _, n := range nodes {
|
||||
if n.Kind == "tool" && n.Label != turnID {
|
||||
t.Fatalf("路 %s 的图里出现了另一路的工具节点 %q(两路又混进同一张图了)", turnID, n.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"turn-a", "turn-b"} {
|
||||
if seen[want] == "" {
|
||||
t.Fatalf("lanes[] 里缺 %s 那一路:%v (%s)", want, seen, body)
|
||||
}
|
||||
}
|
||||
// graph(兼容字段)= 最新一路;两条路 id 必须不同(否则落盘会互相顶掉)。
|
||||
if seen["turn-a"] == seen["turn-b"] {
|
||||
t.Fatalf("两路拿到同一个图 id %q:%v", seen["turn-a"], seen)
|
||||
}
|
||||
var graphID string
|
||||
_ = json.Unmarshal(graph["id"], &graphID)
|
||||
if graphID != seen["turn-b"] {
|
||||
t.Fatalf("graph.id = %q,want 最新一路 %q(后起的那一路最新)", graphID, seen["turn-b"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_SingleLaneHasOneEntry 钉住单路口径:没有真并行时 `lanes[]` 是
|
||||
// 恰好一条(无路标识),`graph` 与它同源 —— 「每一路」在单路下就是那一路,
|
||||
// 不因为引入本键而多出一棵假的第二路。
|
||||
func TestAdminFlowGraph_SingleLaneHasOneEntry(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
env.runFinishedRound(t, "web")
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if len(lanes) != 1 {
|
||||
t.Fatalf("单路的 lanes 条数 = %d,want 1:%s", len(lanes), body)
|
||||
}
|
||||
var turnID string
|
||||
_ = json.Unmarshal(lanes[0]["turnId"], &turnID)
|
||||
if turnID != "" {
|
||||
t.Fatalf("单路的 lanes[0].turnId = %q,want 空(没有路标识 ⇒ 客户端按会话那一张画)", turnID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminFlowGraph_LanesEmptyOnEmptySession 钉住空态:该会话还没有图时 `lanes`
|
||||
// 是空数组(恒非 null)—— 与 `graph:null` 同一诚实口径,客户端不必判空。
|
||||
func TestAdminFlowGraph_LanesEmptyOnEmptySession(t *testing.T) {
|
||||
env := newFlowTestEnv(t, func(string) bool { return true })
|
||||
resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
lanes := flowLanesData(t, body)
|
||||
if lanes == nil {
|
||||
t.Fatalf("`lanes` 键必须恒在且是数组(空时是 []):%s", body)
|
||||
}
|
||||
if len(lanes) != 0 {
|
||||
t.Fatalf("没有图时会话的 lanes = %s,want []", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphDispatchLaneFields 钉住**帧载荷**上的两个新键与它们的缺席语义:
|
||||
// 有路时 `turnId` 在场且与图 id 同源,无路时该键**缺席**(老后端/单路的兼容口径),
|
||||
// `round` 则每次建图都在(客户端据此只保留最新一轮的泳道)。
|
||||
func TestFlowGraphDispatchLaneFields(t *testing.T) {
|
||||
laneGraph := trace.NewRunGraph("web")
|
||||
laneGraph.SetTurnID("turn-a")
|
||||
laneGraph.SetSeq(3)
|
||||
laneGraph.Start(1, time.Now())
|
||||
laneGraph.SeedInput("标记A")
|
||||
raw, err := json.Marshal(laneGraph.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
var id, turnID string
|
||||
var round int
|
||||
_ = json.Unmarshal(got["id"], &id)
|
||||
if _, ok := got["turnId"]; !ok {
|
||||
t.Fatalf("有路的帧缺 `turnId` 键(客户端分不出两条泳道):%s", raw)
|
||||
}
|
||||
_ = json.Unmarshal(got["turnId"], &turnID)
|
||||
_ = json.Unmarshal(got["round"], &round)
|
||||
if id != "web-3#turn-a" || turnID != "turn-a" {
|
||||
t.Fatalf("id/turnId = %q/%q,want web-3#turn-a / turn-a(同源)", id, turnID)
|
||||
}
|
||||
if round != 1 {
|
||||
t.Fatalf("round = %d,want 1(轮次号与会话级序号是两个量)", round)
|
||||
}
|
||||
|
||||
// 无路的图:`turnId` 必须**缺席**(omitempty),客户端据此回落到「会话那一张」。
|
||||
plain := trace.NewRunGraph("web")
|
||||
plain.Start(4, time.Now())
|
||||
plainRaw, err := json.Marshal(plain.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal plain: %v", err)
|
||||
}
|
||||
var plainGot map[string]json.RawMessage
|
||||
if err := json.Unmarshal(plainRaw, &plainGot); err != nil {
|
||||
t.Fatalf("decode plain: %v", err)
|
||||
}
|
||||
if _, ok := plainGot["turnId"]; ok {
|
||||
t.Fatalf("单路的帧不该带 `turnId` 键:%s", plainRaw)
|
||||
}
|
||||
var plainRound int
|
||||
_ = json.Unmarshal(plainGot["round"], &plainRound)
|
||||
if plainRound != 4 {
|
||||
t.Fatalf("单路 round = %d,want 4", plainRound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGraphSourceLanesIsReadOnly 钉住 `lanes[]` 的只读性:补拉快照**不会**发布
|
||||
// 任何新帧、也不会改任何路的图(与 LastGraph 同一纪律)。
|
||||
func TestFlowGraphSourceLanesIsReadOnly(t *testing.T) {
|
||||
published := 0
|
||||
pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true })
|
||||
pub.StartRound(loop.FlowOwnerKey("web", "turn-a"), "标记A")
|
||||
before := published
|
||||
v := pub.LatestLanes("web")
|
||||
if len(v) == 0 {
|
||||
t.Fatal("LatestLanes 为空,但该会话刚起了一路")
|
||||
}
|
||||
if published != before {
|
||||
t.Fatalf("LatestLanes 发布了 %d 帧新图(只读查询绝不能造帧)", published-before)
|
||||
}
|
||||
// 快照是深拷贝:改它不能碰到发布器内部的图。
|
||||
v[0].Nodes = append(v[0].Nodes, trace.FlowNode{ID: "ghost", Kind: "input"})
|
||||
again := pub.LatestLanes("web")
|
||||
for _, g := range again {
|
||||
for _, n := range g.Nodes {
|
||||
if n.ID == "ghost" {
|
||||
t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保证 flowTestEnv 的图形源仍实现 FlowGraphSource(v0.77 多了 LatestLanes)。
|
||||
var _ FlowGraphSource = (*loop.FlowPublisher)(nil)
|
||||
@@ -293,10 +293,10 @@ type ChannelEventStore interface {
|
||||
// 组合根把**同一个**发布器既交给 loop/orchestration(写入面:每帧 span
|
||||
// 发布 `ws.graph_update`)也交给网关(本查询面)。
|
||||
//
|
||||
// 它刻意只声明**一个读方法**:发布、建图、轮次计数与发布会话白名单都留在
|
||||
// 发布器自己手里,管理查询面拿不到、也不该拿到它们(最小权限)——因此
|
||||
// 只读查询**不可能**改变任何会话的实时图,也不可能把面板看不见的会话
|
||||
// 变成可查的(白名单外的会话从未建图,`LastGraph` 自然回 nil)。
|
||||
// 它刻意只声明**两个只读方法**(v0.77 起是两个:`LastGraph` + `LatestLanes`):
|
||||
// 发布、建图、轮次计数与发布会话白名单都留在发布器自己手里,管理查询面拿不到、
|
||||
// 也不该拿到它们(最小权限)——因此只读查询**不可能**改变任何会话的实时图,
|
||||
// 也不可能把面板看不见的会话变成可查的(白名单外的会话从未建图,两者自然回空)。
|
||||
//
|
||||
// 为 nil 时端点诚实作答 500 `flow_unavailable`,绝不回 `graph:null`
|
||||
// (那会把「图源没接线」伪装成「这一轮确实没有图」——
|
||||
@@ -305,6 +305,14 @@ type FlowGraphSource interface {
|
||||
// LastGraph 返回 sessionID 最近一轮图的一份只读快照;该会话还没有图
|
||||
// (从未跑过带 span 的轮次,或本就不在发布会话范围内)时返回 nil。
|
||||
LastGraph(sessionID string) *trace.FlowGraph
|
||||
// LatestLanes 返回 sessionID **最近一轮每一路**的图快照(契约 v0.77 的
|
||||
// `lanes[]`),按路的开始时间升序;没有图时返回空切片。
|
||||
//
|
||||
// 真并行时同一会话同一轮有多条路(design/15 §2.5.5),`LastGraph` 只给出
|
||||
// 其中最新的一条 —— 管理面板要靠本方法才看得全「两路各自的图」,否则
|
||||
// 后起的那一路会把前一路从面板上顶掉(design/15 §2.5.10-1)。它同样是
|
||||
// **只读**的:不发布、不改图、不动轮次计数。
|
||||
LatestLanes(sessionID string) []*trace.FlowGraph
|
||||
}
|
||||
|
||||
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
|
||||
|
||||
@@ -94,9 +94,14 @@ func TestFlowGraphUpdateWireFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// assertGraphKeys 钉住 parseGraph/Flow.tsx 所读的 FlowGraph 顶层键。
|
||||
//
|
||||
// v0.77 起多了两个键(纯增量):`round`(会话轮次号,客户端据此只保留最新一轮的
|
||||
// 泳道)与 `turnId`(这张图所属**路**的权威回合标识,真并行时两路各一个值)。
|
||||
// 前者在每次建图时必有;后者只在「这条路有权威标识」时出现(omitempty),
|
||||
// 因此本断言只钉它在场时可读,缺席语义由 TestFlowGraphDispatchLaneFields 钉住。
|
||||
func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) {
|
||||
t.Helper()
|
||||
want := []string{"id", "nodes", "edges", "sessionId", "startedAt", "endedAt"}
|
||||
want := []string{"id", "nodes", "edges", "sessionId", "round", "startedAt", "endedAt"}
|
||||
if got := sortedKeys(graph); !equalKeys(got, want) {
|
||||
t.Fatalf("graph keys = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user