Commit 78ca2a6fd77a268dfa54b00caeaa1ec8153e6c6c

Authored by yibo
1 parent a2f70192

feat(graph-console): T10 fan-in 等待态可视化 + 下一步不再是单数(27-P4)

新增 join-status.js(+ .check.js 可运行检查,web/ 不在 vitest include 里,
同款手法照抄 io-model.check.js):resolveJoinWait/joinWaitLabel,判据与引擎
activateSuccessTargets/allIncomingSatisfied 同源——只看 forward 的 success
入边,backward/fail 边不算「等待对象」。只有 2+ 条入边的真 fan-in 才出徽标,
单一入边的普通顺序等待不加噪声。

WorkGraphCanvas.vue:pending 节点在等哪几个上游时画一枚「⏳done/total」徽标,
悬停看完整明细(谁 success / 谁还在 running / 谁 fail 了)。

nextNodeId(单数)→ nextNodeIds(数组):advanceBatch(27-P4 T4)一次最多
认领 max_concurrency 个 ready 节点,并行分支下真实会被同批拾取的可能是好几个,
标一个会误导。这个 prop 之前在全仓库没有任何调用方真正传值(NEXT 徽标从未
渲染过),顺手把它接上:GraphRunPane 按 run.max_concurrency 截取候选,
手动模式仍显示单数提示,自动模式显示批量提示。

并行运行中的多节点高亮不需要新代码——.dot.running/.node.running 已经是
按每个节点各自状态渲染,多个节点同时 running 时天然一起亮,这条在 T4/T6
落地时就已经生效。

验证:join-status.check.js 6 条断言全过;`npm run web:build` 编译通过、
零告警。没有做的:没有起真实浏览器点一遍——搭一张真的并行 fan-in 图需要
起完整服务 + 建公司 + 跑真图,这次没做,逻辑侧的验证到 check.js + 编译为止。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
web/src/components/WorkGraphCanvas.vue
@@ -86,10 +86,21 @@ @@ -86,10 +86,21 @@
86 <text v-if="elapsedLabels.get(n.key)" :x="NW - 10" :y="38" text-anchor="end" class="elapsed-label"> 86 <text v-if="elapsedLabels.get(n.key)" :x="NW - 10" :y="38" text-anchor="end" class="elapsed-label">
87 ⏱ {{ elapsedLabels.get(n.key) }} 87 ⏱ {{ elapsedLabels.get(n.key) }}
88 </text> 88 </text>
  89 + <!-- fan-in 等待态(27-P4 T10 / §4.3 决策):pending 节点在等哪几个上游,
  90 + 紧凑徽标 + title 悬停看明细。只有 2+ 条入边的真 fan-in 才画,
  91 + 单一入边的普通顺序等待看紧邻上游状态就够了,不加这个徽标制造噪声。 -->
  92 + <g v-if="joinWaitLabels.get(n.key)" class="join-wait">
  93 + <text :x="NW - 10" :y="38" text-anchor="end" class="join-wait-label">
  94 + {{ joinWaitLabels.get(n.key).text }}
  95 + <title>{{ joinWaitLabels.get(n.key).title }}</title>
  96 + </text>
  97 + </g>
89 <circle :cx="NW - 12" :cy="12" r="5" :class="['dot', n.status]" /> 98 <circle :cx="NW - 12" :cy="12" r="5" :class="['dot', n.status]" />
90 <!-- node_kind='subgraph' 的下钻角标 --> 99 <!-- node_kind='subgraph' 的下钻角标 -->
91 <text v-if="n.nodeKind === 'subgraph'" :x="NW - 8" :y="NH - 7" text-anchor="end" class="sub-badge">⊞</text> 100 <text v-if="n.nodeKind === 'subgraph'" :x="NW - 8" :y="NH - 7" text-anchor="end" class="sub-badge">⊞</text>
92 - <text v-if="n.key === nextNodeId" :x="10" :y="NH - 6" class="next-badge">NEXT ▶</text> 101 + <!-- 「下一步」不再是单数:advanceBatch(27-P4 T4)一次最多认领 maxConcurrency 个
  102 + ready 节点,并行分支下真实会被同批拾取的可能是好几个,标一个会误导。 -->
  103 + <text v-if="nextNodeIdSet.has(n.key)" :x="10" :y="NH - 6" class="next-badge">NEXT ▶</text>
93 </g> 104 </g>
94 </svg> 105 </svg>
95 <div v-else class="empty"> 106 <div v-else class="empty">
@@ -102,6 +113,7 @@ @@ -102,6 +113,7 @@
102 import { computed, ref, watch, onBeforeUnmount } from 'vue'; 113 import { computed, ref, watch, onBeforeUnmount } from 'vue';
103 import { fmtDuration } from './graph-console/node-signals'; 114 import { fmtDuration } from './graph-console/node-signals';
104 import { edgeContractLabel } from './graph-console/io-model'; 115 import { edgeContractLabel } from './graph-console/io-model';
  116 +import { resolveJoinWait, joinWaitLabel } from './graph-console/join-status';
105 117
106 /** 118 /**
107 * 图画布(T3)——从 devloop/UnitsDAG.vue 抽取并扩展。 119 * 图画布(T3)——从 devloop/UnitsDAG.vue 抽取并扩展。
@@ -121,11 +133,15 @@ const props = defineProps({ @@ -121,11 +133,15 @@ const props = defineProps({
121 // graph_run_loop_records 原始行(snake_case),徽标数字取同一 edge_id 的最新一条 133 // graph_run_loop_records 原始行(snake_case),徽标数字取同一 edge_id 的最新一条
122 loopRecords: { type: Array, default: () => [] }, 134 loopRecords: { type: Array, default: () => [] },
123 selectedId: { type: String, default: null }, // defNodeId 135 selectedId: { type: String, default: null }, // defNodeId
124 - nextNodeId: { type: String, default: null }, // 下一步会拾取哪个(§四.C:并行 ready 时必须标出来) 136 + // 下一批 advanceBatch 会拾取哪些(defNodeId 数组)——27-P4 T4 之后不再是单数,
  137 + // 并行分支下真实会被同一批认领的可能是好几个节点。
  138 + nextNodeIds: { type: Array, default: () => [] },
125 hardCap: { type: Number, default: 3 }, // 单边回退硬顶(§四.D.2.1) 139 hardCap: { type: Number, default: 3 }, // 单边回退硬顶(§四.D.2.1)
126 }); 140 });
127 defineEmits(['select']); 141 defineEmits(['select']);
128 142
  143 +const nextNodeIdSet = computed(() => new Set((props.nextNodeIds || []).map(String)));
  144 +
129 const uid = `wg${++instanceCount}`; 145 const uid = `wg${++instanceCount}`;
130 146
131 /** 147 /**
@@ -159,6 +175,20 @@ watch( @@ -159,6 +175,20 @@ watch(
159 ); 175 );
160 onBeforeUnmount(() => clearInterval(ticker)); 176 onBeforeUnmount(() => clearInterval(ticker));
161 177
  178 +/**
  179 + * fan-in 等待态徽标(27-P4 T10):defNodeId → {text, title} | undefined。
  180 + * 与 elapsedLabels 同款理由单独拆 computed——它依赖全部节点+全部边而不是单个节点,
  181 + * 混进 laidOut 会让布局 computed 背上一份不相关的依赖。
  182 + */
  183 +const joinWaitLabels = computed(() => {
  184 + const m = new Map();
  185 + for (const n of props.nodes) {
  186 + const label = joinWaitLabel(resolveJoinWait(n, props.nodes, props.edges));
  187 + if (label) m.set(String(n.defNodeId), label);
  188 + }
  189 + return m;
  190 +});
  191 +
162 const NW = 158, NH = 54, GAP_X = 52, GAP_Y = 18, PAD = 20; 192 const NW = 158, NH = 54, GAP_X = 52, GAP_Y = 18, PAD = 20;
163 const LANE_H = 34, BADGE_W = 34; 193 const LANE_H = 34, BADGE_W = 34;
164 194
@@ -377,6 +407,7 @@ let instanceCount = 0; @@ -377,6 +407,7 @@ let instanceCount = 0;
377 .name-label { font-size:12.5px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; } 407 .name-label { font-size:12.5px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; }
378 .status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; } 408 .status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; }
379 .elapsed-label { font-size:10px; fill:#004085; font-weight:600; font-variant-numeric: tabular-nums; pointer-events:none; } 409 .elapsed-label { font-size:10px; fill:#004085; font-weight:600; font-variant-numeric: tabular-nums; pointer-events:none; }
  410 +.join-wait-label { font-size:10px; fill:#8a4b00; font-weight:600; font-variant-numeric: tabular-nums; cursor:help; }
380 .sub-badge { font-size:13px; fill:#5a67d8; pointer-events:none; } 411 .sub-badge { font-size:13px; fill:#5a67d8; pointer-events:none; }
381 .next-badge { font-size:9px; fill:#0069d9; font-weight:700; pointer-events:none; } 412 .next-badge { font-size:9px; fill:#0069d9; font-weight:700; pointer-events:none; }
382 413
web/src/components/graph-console/GraphRunPane.vue
@@ -28,7 +28,9 @@ @@ -28,7 +28,9 @@
28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button> 28 <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button>
29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button> 29 <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button>
30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button> 30 <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button>
31 - <span v-if="nextNode" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span> 31 + <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
  32 + <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个:
  33 + <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span>
32 </div> 34 </div>
33 35
34 <p v-if="err" class="err">{{ err }}</p> 36 <p v-if="err" class="err">{{ err }}</p>
@@ -40,7 +42,7 @@ @@ -40,7 +42,7 @@
40 :edges="edges" 42 :edges="edges"
41 :loop-records="loopRecords" 43 :loop-records="loopRecords"
42 :selected-id="selectedDefId" 44 :selected-id="selectedDefId"
43 - :next-node-id="nextNode ? String(nextNode.defNodeId) : null" 45 + :next-node-ids="nextNodes.map((n) => String(n.defNodeId))"
44 @select="onSelect" 46 @select="onSelect"
45 > 47 >
46 <template #empty><slot name="empty">这张图还没有节点</slot></template> 48 <template #empty><slot name="empty">这张图还没有节点</slot></template>
@@ -115,8 +117,19 @@ let refreshTimer = null; @@ -115,8 +117,19 @@ let refreshTimer = null;
115 let pollTimer = null; 117 let pollTimer = null;
116 const MAX_EVENTS = 400; 118 const MAX_EVENTS = 400;
117 119
118 -/** 引擎按 (created_at, id) 取第一个 ready 节点;getRunState 用同样的排序,取首个 ready 即「下一步会跑哪个」 */  
119 -const nextNode = computed(() => nodes.value.find((n) => n.status === 'ready') || null); 120 +// 27-P4 T4:手动模式的「下一步」(nextStep→advanceOne)仍然只推一个,但自动模式的
  121 +// advanceBatch 一次最多认领 run.max_concurrency 个 ready 节点——「下一步会跑哪个」
  122 +// 不再是单数。引擎按 (created_at, id) 取候选,getRunState 用同样的排序返回 nodes,
  123 +// 取前 N 个 ready 即「下一批大概率会认领谁」。
  124 +// ponytail: 这是提示,不是承诺——真实拾取时若跟别的 advanceBatch 撞了车(§4.1 决策),
  125 +// 实际认领的集合可能有出入;纯展示用途,不影响任何控制逻辑,不值得为了精确复刻
  126 +// 后端排序再传一次 max_concurrency 的默认值同步逻辑。
  127 +const DEFAULT_MAX_CONCURRENCY_HINT = 5; // 与 work-graph-db.DEFAULT_MAX_CONCURRENCY 对齐,纯展示兜底
  128 +const nextNodes = computed(() => {
  129 + const cap = (run.value && run.value.max_concurrency) || DEFAULT_MAX_CONCURRENCY_HINT;
  130 + return nodes.value.filter((n) => n.status === 'ready').slice(0, cap);
  131 +});
  132 +const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数
120 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null); 133 const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
121 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status)); 134 const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));
122 135
web/src/components/graph-console/join-status.check.js 0 → 100644
  1 +/**
  2 + * join-status 的可运行检查(`node web/src/components/graph-console/join-status.check.js`)。
  3 + * 同款手法见 io-model.check.js 的头注——web/ 不在 vitest include 里,用 assert 自跑。
  4 + */
  5 +import assert from 'node:assert/strict';
  6 +import { resolveJoinWait, joinWaitLabel } from './join-status.js';
  7 +
  8 +const nodes = [
  9 + { defNodeId: 'entry', name: 'entry', status: 'success' },
  10 + { defNodeId: 'a', name: 'a', status: 'success' },
  11 + { defNodeId: 'b', name: 'b', status: 'running' },
  12 + { defNodeId: 'c', name: 'c', status: 'fail' },
  13 + { defNodeId: 'join', name: 'join', status: 'pending' },
  14 + { defNodeId: 'chain2', name: 'chain2', status: 'pending' },
  15 +];
  16 +const edges = [
  17 + { from: 'entry', to: 'a', condition: 'success', edgeKind: 'forward' },
  18 + { from: 'entry', to: 'b', condition: 'success', edgeKind: 'forward' },
  19 + { from: 'entry', to: 'c', condition: 'success', edgeKind: 'forward' },
  20 + { from: 'a', to: 'join', condition: 'success', edgeKind: 'forward' },
  21 + { from: 'b', to: 'join', condition: 'success', edgeKind: 'forward' },
  22 + { from: 'c', to: 'join', condition: 'success', edgeKind: 'forward' },
  23 + { from: 'a', to: 'chain2', condition: 'success', edgeKind: 'forward' },
  24 + // backward 边不参与等待判定,即使指向 join 也不该被数进 waiting
  25 + { from: 'join', to: 'a', condition: 'fail', edgeKind: 'backward' },
  26 +];
  27 +
  28 +// ① 真正的 fan-in:join 在等 b(running)/c(fail),a 已经 success
  29 +{
  30 + const wait = resolveJoinWait(nodes.find((n) => n.defNodeId === 'join'), nodes, edges);
  31 + assert.equal(wait.total, 3);
  32 + assert.equal(wait.done, 1);
  33 + assert.equal(wait.isFanIn, true);
  34 + assert.equal(wait.satisfied, false);
  35 + assert.deepEqual(wait.blockedBy.map((w) => w.defNodeId).sort(), ['b', 'c']);
  36 + const label = joinWaitLabel(wait);
  37 + assert.equal(label.text, '⏳1/3');
  38 + assert.match(label.title, /a\(success\)/);
  39 + assert.match(label.title, /b\(running\)/);
  40 + assert.match(label.title, /c\(fail\)/);
  41 +}
  42 +
  43 +// ② 单一入边的普通顺序等待:不是 fan-in,不出徽标(避免给每一条链都加噪声)
  44 +{
  45 + const wait = resolveJoinWait(nodes.find((n) => n.defNodeId === 'chain2'), nodes, edges);
  46 + assert.equal(wait.total, 1);
  47 + assert.equal(wait.isFanIn, false);
  48 + assert.equal(joinWaitLabel(wait), null);
  49 +}
  50 +
  51 +// ③ 非 pending 节点:不适用(已经跑过或正在跑,没有「在等谁」这回事)
  52 +{
  53 + assert.equal(resolveJoinWait(nodes.find((n) => n.defNodeId === 'a'), nodes, edges), null);
  54 +}
  55 +
  56 +// ④ 已满足(引擎下一拍就会点亮,这一刻不该显示「在等」)
  57 +{
  58 + const satisfiedNodes = nodes.map((n) => (n.defNodeId === 'b' || n.defNodeId === 'c'
  59 + ? { ...n, status: 'success' } : n));
  60 + const wait = resolveJoinWait(satisfiedNodes.find((n) => n.defNodeId === 'join'), satisfiedNodes, edges);
  61 + assert.equal(wait.satisfied, true);
  62 + assert.equal(joinWaitLabel(wait), null);
  63 +}
  64 +
  65 +// ⑤ 没有 forward success 入边(图的入口节点):不适用
  66 +{
  67 + assert.equal(resolveJoinWait({ defNodeId: 'entry', status: 'pending' }, nodes, edges), null);
  68 +}
  69 +
  70 +console.log('join-status.check.js: 全部通过');
web/src/components/graph-console/join-status.js 0 → 100644
  1 +/**
  2 + * fan-in 等待态(27-P4 T10):一个 pending 节点在等哪几个上游、各自是什么状态。
  3 + *
  4 + * 并行(27-P4 T4/T6)落地之后一张图可能同时有好几个分支在跑,「这个节点为什么还没轮到」
  5 + * 不再是一眼能看出来的事——以前只有一条链,pending 的下一个必然是紧邻上游那一个;
  6 + * 现在它可能在等三个并行分支里最慢的那一个。§4.3 决策要求「run 层面要能回答『我卡在等
  7 + * 哪几个节点』」,这里就是那句话的数据源。
  8 + *
  9 + * 判据与引擎 `graph-engine.activateSuccessTargets`/`allIncomingSatisfied` 同源:
  10 + * 只看 forward 的 success 入边——backward 边是异常流程,不参与「汇合点」这道闸,
  11 + * fail 边同理。跟 io-model.js 的 inboundEdges 用的是同一条筛选条件,别改出分叉。
  12 + */
  13 +
  14 +/**
  15 + * @param {object} node getRunState 的合并节点(要 status 与 defNodeId 两个键)
  16 + * @param {Array} nodes 同图全部节点(拿名字与上游状态用)
  17 + * @param {Array} edges 同图全部边
  18 + * @returns {null | {
  19 + * waiting: Array<{defNodeId, name, status}>,
  20 + * total: number, done: number, satisfied: boolean,
  21 + * isFanIn: boolean, // 2+ 条 forward success 入边才算真正的 fan-in;单一入边只是普通顺序等待
  22 + * blockedBy: Array<{defNodeId, name, status}>,
  23 + * }}
  24 + */
  25 +export function resolveJoinWait(node, nodes = [], edges = []) {
  26 + if (!node || node.status !== 'pending') return null;
  27 + const incoming = (edges || []).filter((e) => (
  28 + String(e.to) === String(node.defNodeId)
  29 + && (e.condition || 'success') === 'success'
  30 + && (e.edgeKind || 'forward') !== 'backward'
  31 + ));
  32 + if (!incoming.length) return null; // 没有 forward success 入边:不是在等 fan-in,是别的原因还没轮到
  33 +
  34 + const byId = new Map((nodes || []).map((n) => [String(n.defNodeId), n]));
  35 + const waiting = incoming.map((e) => {
  36 + const src = byId.get(String(e.from));
  37 + return {
  38 + defNodeId: e.from,
  39 + name: (src && (src.name || src.defNodeId)) || e.from,
  40 + status: (src && src.status) || 'pending',
  41 + };
  42 + });
  43 +
  44 + const done = waiting.filter((w) => w.status === 'success').length;
  45 + return {
  46 + waiting,
  47 + total: waiting.length,
  48 + done,
  49 + satisfied: done === waiting.length,
  50 + isFanIn: waiting.length > 1,
  51 + blockedBy: waiting.filter((w) => w.status !== 'success'),
  52 + };
  53 +}
  54 +
  55 +/**
  56 + * 画布上的紧凑摘要——克制原则同 `edgeContractLabel`:只占一个小徽标的位置,
  57 + * 完整明细留给 title。`satisfied` 时不显示(该节点这一刻就会被点亮,不算「在等」);
  58 + * 非 fan-in(只有一条入边)时也不显示——普通顺序等待,看紧邻上游的状态就够了,
  59 + * 加一个「1/1」徽标只会制造噪声。
  60 + */
  61 +export function joinWaitLabel(wait) {
  62 + if (!wait || !wait.isFanIn || wait.satisfied) return null;
  63 + return {
  64 + text: `⏳${wait.done}/${wait.total}`,
  65 + title: `在等:${wait.waiting.map((w) => `${w.name}(${w.status})`).join(' · ')}`,
  66 + };
  67 +}