复核修正(前端):节点计数、并行数、「当前在跑」不把子工作流里的步骤另算;子图那一步写「子工作流 › 那一步」

- B5 run/trace:子工作流里的步骤(isSubStep:节点 id「子工作流节点 id/…」)算在子工作流那一步里——完成数不另算(以前运行详情
  会写「7/3」),没有 run.started 时的分母也不算它们;Telemetry 的「当前「X」等 N 个」同一个规矩,子工作流和它里面那一步不算成
  两个同时在跑
- B6 run/trace.stepLabel:图上没有的子图步骤接上子工作流的名字(「取数子流程 › 查订单」,和服务端终态事件的 label 同一个拼法,
  名字取 node.started 记的);运行胶囊(RunHud)、泳道(RunTimeline)的失败、阻断说法和泳道名、记录页的失败横幅与航迹
  (RunDetailView.labelOf)都用它,不再写「失败于「sub/x」」

检查脚本:check-decode 加两段(子工作流步骤的完成数、并行数、当前在跑;出错在子图里那一步时的名字)
This commit is contained in:
ThearchyHelios
2026-10-18 18:18:53 +08:00
parent 32dbfcfb52
commit 3e6032bdca
6 changed files with 72 additions and 16 deletions
+6 -4
View File
@@ -7,7 +7,7 @@ import { formatClock, formatDuration, formatLapse, formatOffset, formatSpan, for
import { statusMeta } from '../lib/status'
import { topology, withSubSteps, type GraphLike } from '../run/derive'
import {
isActivePhase, isTerminal, lastEventAt, liveAt, project, type NodeState, type NodeTrace, type Projection,
isActivePhase, isTerminal, lastEventAt, liveAt, project, stepLabel, type NodeState, type NodeTrace, type Projection,
type Segment, type Trace,
} from '../run/trace'
import { projectCached } from '../run/useNodeView'
@@ -341,7 +341,9 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map<s
const byId = new Map(graph.nodes.map((n) => [n.id, n]))
// 子工作流里的步骤排在子工作流那一行下面(withSubSteps),老运行里改过图的照旧排在最后
const ids = withSubSteps(topo.order, Object.keys(trace.nodes).filter((id) => !byId.has(id)))
const failedLabel = trace.failedNodeId ? labels.get(trace.failedNodeId) ?? trace.failedNodeId : ''
const own = (id: string) => labels.get(id)
// 子工作流里的那一步图上没有:接上子工作流的名字(stepLabel)
const failedLabel = trace.failedNodeId ? stepLabel(trace, trace.failedNodeId, own) : ''
const rows: Row[] = []
let top = 0
for (const id of ids) {
@@ -350,7 +352,7 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map<s
const p = states?.[id]
const state: NodeState = states ? p?.state ?? 'idle' : n?.state ?? 'idle'
const type = nodeTypeOf(byId.get(id))
const label = labels.get(id) ?? id
const label = stepLabel(trace, id, own)
const tokens = states ? (p?.tokensIn ?? 0) + (p?.tokensOut ?? 0) : undefined
rows.push({
key: id, kind: 'node', nodeId: id, label, type,
@@ -780,7 +782,7 @@ export function RunTimeline({
<button type="button" className="tl-alert" onClick={() => onFocusNode?.(trace.failedNodeId!)}
title="在画布上定位到失败的节点">
<StatusBadge status="failed" size={11} animate={false} decorative />
<span className="min-w-0 truncate">失败于「{labels.get(trace.failedNodeId) ?? trace.failedNodeId}」</span>
<span className="min-w-0 truncate">失败于「{stepLabel(trace, trace.failedNodeId, (x) => labels.get(x))}」</span>
</button>
)}
{trace.phase === 'waiting' && trace.waitingNodeId && (
+4 -4
View File
@@ -13,7 +13,7 @@ import { catalogDrift, decodePhase, decodeRun, exitLabels, schemaDrift, summariz
import { sourceHandles } from '../../canvas/nodeDefs'
import { EffectsPane, RunTaskCards, useRunTasks } from './RunTasks'
import { OriginChip } from './RunList'
import { runStatusOf, type RunPhase } from '../../run/trace'
import { runStatusOf, stepLabel, type RunPhase } from '../../run/trace'
import { useCatalog } from '../../store/catalog'
import type { Approval, Run, RunEvent } from '../../types'
import { CatalogDriftBanner, FailedBanner, FeedbackStrip, HeldBanner, WaitingBanner, type Feedback } from './Banners'
@@ -161,9 +161,9 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
const labelOf = useCallback((id?: string | null) => {
if (!id) return undefined
const n = d.graph?.nodes.find((x) => x.id === id)
return n?.data?.label || id
}, [d.graph])
// 子工作流里的那一步(出错、被驳回在它上面时运行记下的节点是「sub/x」)图上没有:接上子工作流的名字(stepLabel)
return stepLabel(d.trace, id, (x) => d.graph?.nodes.find((n) => n.id === x)?.data?.label || undefined)
}, [d.graph, d.trace])
if (!run) {
if (d.state === 'error') {
+3 -2
View File
@@ -7,7 +7,7 @@ import {
NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId,
} from '../../lib/format'
import { type StatusCode } from '../../lib/status'
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
import { isExecuting, isSubStep, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
import { useRunClock } from '../../run/useRunClock'
import type { Approval, Run } from '../../types'
import { BUNDLE_TEXT, COST_TEXT, REJECTED_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
@@ -96,7 +96,8 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf
const unpriced = (settled || !streaming ? (isNum(usage.unpriced_calls) ? usage.unpriced_calls : undefined) : undefined)
?? trace.unpricedCalls
const running = Object.values(trace.nodes).filter(isExecuting)
// 子工作流里的那一步算在子工作流那一步上(isSubStep),不和它一起算成两个同时在跑
const running = Object.values(trace.nodes).filter((n) => isExecuting(n) && !isSubStep(n.id))
const waitingId = trace.waitingNodeId ?? pending?.[0]?.node_id
const waitedMs = trace.waitingSince != null ? liveAt(trace, now) - trace.waitingSince
: ageMs(pending?.[0]?.created_at, now)
+3 -2
View File
@@ -14,7 +14,7 @@ import { structureSig, useStudio } from '../store/studio'
import type { RunEvent } from '../types'
import { topology } from './derive'
import {
isActivePhase, isSettled, isTerminal, liveAt, project, type NodeState, type RunPhase, type Trace,
isActivePhase, isSettled, isTerminal, liveAt, project, stepLabel, type NodeState, type RunPhase, type Trace,
} from './trace'
import { useRunClock } from './useRunClock'
@@ -204,7 +204,8 @@ export function useRunGlance(active: boolean): RunGlance {
const order = useMemo(() => topology({ nodes, edges }).order, [nodes, edges])
const labels = useMemo(() => new Map(nodes.map((n) => [n.id, n.data.label || n.id])), [nodes])
const label = (id: string) => labels.get(id) ?? id
// 子工作流里的那一步(失败、被驳回在它上面)图上没有:接上子工作流的名字(stepLabel)
const label = (id: string) => stepLabel(trace, id, (x) => labels.get(x))
const live = isActivePhase(phaseLive)
const at = replayAt ?? (live ? liveAt(trace, now) : lastStampOf(trace))
+26 -4
View File
@@ -91,6 +91,8 @@ export interface NodeTrace {
*/
exited?: boolean
exitTo?: string[]
/** node.started 上记的名字。子工作流里的步骤不在画布的图上,名字从这里取(stepLabel) */
label?: string
/** 用量随时间的累计,回放时按游标取那一刻的 tokens 和工具数。没调过模型和工具的节点没有 */
marks?: UsageMark[]
}
@@ -277,6 +279,25 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb
return -1
}
/**
* 子工作流里的一步(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md §2):它在子工作流节点那一步
* 里面跑,节点计数、并行数、「当前在跑」都算在子工作流那一步上,不另算
*/
export const isSubStep = (id: string): boolean => id.includes('/')
/**
* 一个节点给人看的名字:own 是画布的图上的名字(找不到为 undefined)。子工作流里的步骤(isSubStep)图上没有,接上子工作流
* 那一步的名字:「取数子流程 › 查订单」——和服务端终态事件的 label 同一个拼法(runner._label_of),失败横幅、泳道、
* 运行胶囊不再写裸的「sub/fetch」。别的找不到的照旧写 id
*/
export function stepLabel(t: Trace, id: string, own: (id: string) => string | undefined): string {
const mine = own(id)
if (mine) return mine
if (!isSubStep(id)) return id
const cut = id.lastIndexOf('/')
return `${stepLabel(t, id.slice(0, cut), own)} › ${t.nodes[id]?.label || id.slice(cut + 1)}`
}
/**
* 此刻同时在跑的数量。
*
@@ -286,8 +307,7 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb
function parallelOf(nodes: Record<string, NodeTrace>): number {
let total = 0
for (const n of Object.values(nodes)) {
// 子工作流里的那几步(「子工作流节点 id/…」)在子工作流节点那一格里跑,不另算一路并行
if (n.id.includes('/')) continue
if (isSubStep(n.id)) continue
let inner = 0
let run = 0
for (const s of n.segments) {
@@ -521,6 +541,7 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace {
case 'node.started': {
if (!nodeId) break
const n = node(nodeId)
if (typeof d.label === 'string' && d.label && n.label !== d.label) n.label = d.label
const open = openIndex(n.segments, 'run')
const lastSeg = n.segments[n.segments.length - 1]
if (open >= 0) {
@@ -1012,7 +1033,8 @@ export function project(t: Trace, at: number): Projection {
toolsRunning: state === 'running' ? m?.[5] ?? 0 : 0,
}
}
if (view.state === 'done' || view.state === 'skipped') nodesDone += 1
// 分母是工作流里的节点数:子工作流里的步骤算在子工作流那一步里(isSubStep),不另算完成数
if ((view.state === 'done' || view.state === 'skipped') && !isSubStep(n.id)) nodesDone += 1
nodes[n.id] = view
}
@@ -1039,7 +1061,7 @@ export function project(t: Trace, at: number): Projection {
return {
phase, elapsedMs, activeMs, waitMs, nodesDone,
nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).length,
nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).filter((id) => !isSubStep(id)).length,
parallelNow, ...usage, nodes,
}
}
+30
View File
@@ -3030,6 +3030,36 @@ await section('子工作流里的每一步进了同一个运行(节点 id「
check('子工作流里的一步:「取数子流程 › 查订单」', title('sub/fetch') === '取数子流程 › 查订单', title('sub/fetch'))
check('没起名字的(名字就是子图里的 id)退到节点类型', title('sub/o') === '取数子流程 › 成果', title('sub/o'))
check('子工作流节点自己照旧', title('sub') === '取数子流程', title('sub'))
// 航迹的计数:子工作流里的步骤算在子工作流那一步里,完成数的分母是工作流里的节点数(run.started 的 nodes)
const trace = await import(await load('/src/run/trace.ts'))
const evs = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'start', {}), E(3, 'node.finished', 'start', {}),
E(4, 'node.started', 'sub', { label: '取数子流程' }),
E(5, 'node.started', 'sub/fetch', { label: '查订单' }), E(6, 'node.finished', 'sub/fetch', {}),
E(7, 'node.started', 'sub/o', {})]
const t = evs.reduce(trace.foldEvent, trace.emptyTrace())
const mid = trace.project(t, 7500)
check('跑到子工作流里:完成数只数工作流里的节点(1/2,不是 2/2)', mid.nodesDone === 1 && mid.nodesTotal === 2,
`${mid.nodesDone}/${mid.nodesTotal}`)
check('……子工作流和它里面那一步不算成两路并行', mid.parallelNow === 1, String(mid.parallelNow))
check('……「当前在跑」只认子工作流那一步', Object.values(t.nodes).filter((n) => trace.isExecuting(n) && !trace.isSubStep(n.id))
.map((n) => n.id).join() === 'sub')
const done = [...evs, E(8, 'node.finished', 'sub/o', {}), E(9, 'node.finished', 'sub', {}),
E(10, 'run.finished', null, { output: {} })].reduce(trace.foldEvent, trace.emptyTrace())
const end = trace.project(done, 10000)
check('跑完:2/2,不是 4/2', end.nodesDone === 2 && end.nodesTotal === 2, `${end.nodesDone}/${end.nodesTotal}`)
// 子工作流里的一步出错:运行记下的出错节点是「sub/fetch」,图上没有它。失败横幅、泳道、运行胶囊照「子工作流 › 那一步」写
const failedRun = [...evs.slice(0, 5), E(6, 'node.failed', 'sub/fetch', { error: '查询失败' }),
E(7, 'node.failed', 'sub', { error: '查询失败' }),
E(8, 'run.failed', null, { error: '查询失败', node_id: 'sub/fetch', label: '取数子流程 › 查订单' })]
.reduce(trace.foldEvent, trace.emptyTrace())
const own = (id) => ({ start: '入口', sub: '取数子流程' })[id]
check('出错节点记的是子图里那一步', failedRun.failedNodeId === 'sub/fetch', failedRun.failedNodeId)
check('名字接上子工作流:「取数子流程 › 查订单」,不写裸 id', trace.stepLabel(failedRun, 'sub/fetch', own) === '取数子流程 › 查订单',
trace.stepLabel(failedRun, 'sub/fetch', own))
check('图上的节点照旧写图上的名字', trace.stepLabel(failedRun, 'sub', own) === '取数子流程')
check('没起过名字的子图步骤写子图里的 id', trace.stepLabel(t, 'sub/o', own) === '取数子流程 › o', trace.stepLabel(t, 'sub/o', own))
})
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')