diff --git a/frontend/src/canvas/FlowCanvas.tsx b/frontend/src/canvas/FlowCanvas.tsx index 4f6f714e..013f9108 100644 --- a/frontend/src/canvas/FlowCanvas.tsx +++ b/frontend/src/canvas/FlowCanvas.tsx @@ -196,6 +196,8 @@ const MINI_FILL: Partial> = { done: 'color-mix(in srgb, var(--st-done) 70%, transparent)', skipped: 'var(--st-skipped)', cancelled: 'var(--st-cancelled)', + // 被驳回:人的决定,和取消同一种中性色(不画失败色) + rejected: 'var(--st-cancelled)', suspended: 'var(--st-suspended)', blocked: 'var(--st-blocked)', unreached: 'color-mix(in srgb, var(--st-unreached) 45%, transparent)', diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 624024f9..00747e0a 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -18,7 +18,7 @@ import { explainRunError } from '../lib/explain' import { formatDuration, formatLapse, formatNumber, formatTokens, NONE, shortId } from '../lib/format' import { statusMeta } from '../lib/status' import { - CHALLENGE_TEXT, claimTally, DUAL_CHECK_TEXT, issuanceLabel, reportStampText, RUN_CLASS_LABEL, UPGRADE_POLICY_LABEL, + CHALLENGE_TEXT, claimTally, DUAL_CHECK_TEXT, issuanceLabel, REJECTED_TEXT, reportStampText, RUN_CLASS_LABEL, UPGRADE_POLICY_LABEL, } from '../lib/terms' import { useReportStamp, type ReportStamp } from './reportStamp' import { ApprovalCard } from '../run/RunPanel' @@ -559,6 +559,14 @@ function readingOf({ state, view, type, config, skewMs, timed, fanIn, facts, sin noteTitle: why.title, } } + case 'rejected': + // 被人驳回(node.failed 带 reason=rejected):人的决定,不是失败。状态字已经写了「被驳回」,槽里写原话的第一句 + // (「人工驳回:数字对不上」),整句放悬停;不按失败去归类、讲处理建议 + return { + main: formatDuration(n?.lastDurationMs ?? runtime?.durationMs), + note: error ? firstSentence(error) : REJECTED_TEXT.card, + noteTitle: error || undefined, + } case 'queued': return { main: '', note: fanIn > 1 ? '等待其余上游完成' : '上游已交付,等待开始' } case 'skipped': diff --git a/frontend/src/canvas/surface.css b/frontend/src/canvas/surface.css index f010c52d..bc36fc27 100644 --- a/frontend/src/canvas/surface.css +++ b/frontend/src/canvas/surface.css @@ -1443,6 +1443,8 @@ background: color-mix(in srgb, var(--st-failed) 45%, transparent); } .tl-s-cancelled { --c: var(--st-cancelled); } +/* 被驳回的那一段:人的决定,和取消同一种中性色 */ +.tl-s-rejected { --c: var(--st-cancelled); } .tl-s-suspended { --c: var(--st-suspended); } .tl-s-waiting { --c: var(--st-waiting); } .tl-s-waited { --c: var(--st-waiting); opacity: 0.8; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 70aa7a52..ac5af16e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -830,6 +830,15 @@ .node-cancelled .nc-slot { background: var(--st-cancelled); } +/* 被人驳回的那一步(node.failed 带 reason=rejected):人的决定,不是故障。和取消同一种中性色,不画失败的红框、不抖动 */ +.nc.node-rejected { + --st-c: var(--st-cancelled); + border-color: color-mix(in srgb, var(--st-cancelled) 70%, var(--border)); +} +.node-rejected .nc-slot { + width: 5px; + background: var(--st-cancelled); +} .nc.node-suspended { --st-c: var(--st-suspended); border-color: color-mix(in srgb, var(--st-suspended) 70%, var(--border)); diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index c3744e5b..b7d8723c 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5439,6 +5439,18 @@ export const REJECTED_TEXT = { }, /** 问数据:这一轮的运行被驳回结束时,补救区的那一句 */ chatReason: '这一轮的运行被审批人驳回,已经结束。这是人的决定,不是故障;改好内容后可以重新运行。', + /** + * 被驳回的那一步自己(node.failed 带 reason=rejected):时间线、问数据过程里这一步的副标题。步骤名就是节点名,不再写一遍 + * 「在哪一步」;谁、备注照写,超时驳回写按设置驳回 + */ + step: (d: { actor?: unknown; note?: unknown; decision?: unknown }): string => { + const who = typeof d.actor === 'string' ? d.actor.trim() : '' + const note = typeof d.note === 'string' ? d.note.trim() : '' + if (d.decision === 'expired_reject') return '超过时限,按设置驳回' + return `${who ? `${who} 驳回` : '被驳回'}${note ? `:${note}` : ''}` + }, + /** 画布节点卡上被驳回的那一步:一句话,悬停写原话 */ + card: '被驳回', } /** 来源标签:老运行没有来源时写「来源未记录」 */ diff --git a/frontend/src/pages/runs/TracePane.tsx b/frontend/src/pages/runs/TracePane.tsx index 73dbbbdb..c165b23c 100644 --- a/frontend/src/pages/runs/TracePane.tsx +++ b/frontend/src/pages/runs/TracePane.tsx @@ -612,6 +612,8 @@ function NodeCard({ id, trace, proj, state, graph, facts, at, labelOf, href, gon if (n?.takenHandle && (state === 'done' || state === 'failed')) rows.push(['出口', {n.takenHandle}]) if (n?.reason) rows.push(['理由', n.reason]) if (state === 'skipped' && n?.skippedReason) rows.push(['跳过', n.skippedReason]) + // 被人驳回的那一步(node.failed 带 reason=rejected):写原话(谁驳回、为什么),不按失败去讲原因和处理建议 + if (state === 'rejected' && n?.error) rows.push([REJECTED_TEXT.title, n.error]) // 卡片说的是游标那一刻所在的那一次执行(和画布卡片同一个划分):循环上一轮、接着跑之前 // 的事不挂到这一次干净的执行上;回放到更早的一次时,也不因为后面还有一件就看不到这一次的 const since = execStart(n, at, facts) diff --git a/frontend/src/run/RunHud.tsx b/frontend/src/run/RunHud.tsx index f18e3837..b90bbc43 100644 --- a/frontend/src/run/RunHud.tsx +++ b/frontend/src/run/RunHud.tsx @@ -458,7 +458,7 @@ function Cells({ cells, onPick }: { cells: RunGlance['cells']; onPick: (id: stri } const COUNT_ORDER: NodeState[] = [ - 'running', 'waiting', 'failed', 'suspended', 'done', 'queued', 'blocked', 'unreached', 'skipped', 'cancelled', + 'running', 'waiting', 'failed', 'rejected', 'suspended', 'done', 'queued', 'blocked', 'unreached', 'skipped', 'cancelled', ] // ------------------------------------------------------------------------- diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 6e2eeeb7..ba6d6a92 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -93,7 +93,8 @@ export type StepKind = * suspended:服务重启时还在跑的步骤,断点还在,可以接着跑。 * skipped:skip_if 成立、这一步没有执行。和"根本没轮到"是两回事,得留痕。 */ -export type StepStatus = 'running' | 'done' | 'failed' | 'waiting' | 'cancelled' | 'suspended' | 'skipped' +/** rejected:这一步被人驳回(node.failed 带 reason=rejected)。人的决定,不是失败:不画红、不算出错 */ +export type StepStatus = 'running' | 'done' | 'failed' | 'waiting' | 'cancelled' | 'suspended' | 'skipped' | 'rejected' /** 节点的某一次执行。循环体、轮询类节点一次运行里会执行几十上百次 */ export interface Exec { @@ -394,7 +395,7 @@ export function decodePhase(events: RunEvent[], final?: RunFinal): RunPhase { const STEP_OF: Partial> = { done: 'done', failed: 'failed', waiting: 'waiting', cancelled: 'cancelled', - suspended: 'suspended', running: 'running', skipped: 'skipped', + suspended: 'suspended', running: 'running', skipped: 'skipped', rejected: 'rejected', } const stepStatusOf = (state: NodeState): StepStatus => STEP_OF[state] ?? 'done' @@ -2018,10 +2019,31 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt } case 'node.failed': { - // 节点失败时还在进行中的那次调用就是出错的那次,跟节点一起算失败,不留给清扫写成已取消 - closeLlm(nodeId, 'failed', at) const step = nodeId ? nodeSteps.get(nodeId) : undefined const ms = num(d.duration_ms) + if (d.reason === 'rejected') { + // 人的驳回(服务端给被驳回的那一步的 node.failed 带 reason=rejected,总方案 X9):这一步写「被驳回」,副标题写谁、 + // 备注,原话放展开里;不画红、不当成出错(老运行的 node.failed 没有 reason,照旧按失败画) + closeLlm(nodeId, 'cancelled', at) + const sub = REJECTED_TEXT.step(d) + if (step) { + const cur = execOf(nodeId) + if (cur) { cur.status = 'rejected'; if (ms != null) cur.ms = ms } + step.status = 'rejected' + step.level = undefined + step.sub = sub + step.detail = String(d.error ?? '') + step.ms = ms + step.meta = dur(ms) + } else { + push({ id: `nr-${seq}`, seq, kind: 'lifecycle', status: 'rejected', title: REJECTED_TEXT.title, sub, + ...(d.error ? { detail: String(d.error) } : {}), nodeId }, nodeId) + } + trackTeam(event) + break + } + // 节点失败时还在进行中的那次调用就是出错的那次,跟节点一起算失败,不留给清扫写成已取消 + closeLlm(nodeId, 'failed', at) // detail 是原始异常:给排查用,不给人读,折起来 const raw = typeof d.detail === 'string' && d.detail ? d.detail : undefined if (step) { diff --git a/frontend/src/run/derive.ts b/frontend/src/run/derive.ts index 5313c375..3d669e68 100644 --- a/frontend/src/run/derive.ts +++ b/frontend/src/run/derive.ts @@ -224,7 +224,7 @@ export function heldEdgesOf(trace: Trace, graph: GraphLike): string[] { return out } -const STARTED = new Set(['running', 'waiting', 'done', 'failed', 'skipped', 'cancelled', 'suspended']) +const STARTED = new Set(['running', 'waiting', 'done', 'failed', 'skipped', 'cancelled', 'suspended', 'rejected']) /** * 这次运行实际走过的边:源放行过这条出口(循环里任何一轮都算),目标也确实开跑过。 diff --git a/frontend/src/run/trace.ts b/frontend/src/run/trace.ts index d0aedd17..b9a29860 100644 --- a/frontend/src/run/trace.ts +++ b/frontend/src/run/trace.ts @@ -21,9 +21,13 @@ import { applyDerived, type GraphLike } from './derive' export type RunPhase = | 'idle' | 'queued' | 'running' | 'waiting' | 'succeeded' | 'failed' | 'cancelled' | 'suspended' +/** + * rejected:这一步被人驳回(node.failed 带 reason=rejected,总方案 X9)。是人的决定、不是故障:不画失败色,不算「失败于 X」, + * 下游也不是「被阻断」(运行收成了 cancelled,没走到的照旧是未到达)。老运行的 node.failed 没有 reason,照旧是 failed + */ export type NodeState = | 'idle' | 'queued' | 'running' | 'waiting' | 'done' | 'failed' | 'skipped' - | 'cancelled' | 'suspended' | 'blocked' | 'unreached' + | 'cancelled' | 'suspended' | 'blocked' | 'unreached' | 'rejected' export interface Segment { start: number @@ -568,6 +572,8 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (!nodeId) break const n = node(nodeId) const failed = type === 'node.failed' + // 被人驳回的那一步(服务端在 node.failed 上带 reason=rejected):这一段收成「被驳回」,不是失败 + const rejected = failed && d.reason === 'rejected' // 节点跑完了,决定已经生效(兜底 human.resolved 没发出来的老运行) if (!failed && next.book.asking[nodeId]) delete book().asking[nodeId] const ms = num(d.duration_ms) @@ -583,7 +589,7 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (open >= 0) { const seg = n.segments[open] if (!tsOk && ms != null) end = seg.start + ms - patchSeg(n, open, { end, status: failed ? 'failed' : 'done' }) + patchSeg(n, open, { end, status: rejected ? 'rejected' : failed ? 'failed' : 'done' }) } // 容器里还开着的成员、调度段:节点都结束了,它们不可能还在跑 n.segments.forEach((s, i) => { @@ -597,10 +603,11 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { // 漏了 tool.end 的工具随节点一起收掉;记一条刻度,下一轮回放时不带着上一轮的「在跑」 if (n.toolsRunning) { n.toolsRunning = 0; mark(n) } n.looping = looping - n.state = failed ? 'failed' : looping ? 'running' : 'done' + n.state = rejected ? 'rejected' : failed ? 'failed' : looping ? 'running' : 'done' if (failed) { + // 驳回的原话照样留着(卡片、航迹悬停写得出是谁驳回、为什么),但它不是「最近一次失败」:不拿它当失败的节点 n.error = String(d.error ?? '') - book().lastFailed = nodeId + if (!rejected) book().lastFailed = nodeId } else { n.attempt = num(d.attempt) ?? n.attempt } diff --git a/frontend/src/run/useNodeView.ts b/frontend/src/run/useNodeView.ts index 73ddba82..7bfc74cc 100644 --- a/frontend/src/run/useNodeView.ts +++ b/frontend/src/run/useNodeView.ts @@ -91,7 +91,7 @@ export function replayTraceCached(trace: Trace, at: number, graph: GraphLike): T } const state = p?.state ?? 'idle' // 人工审批的通过 / 驳回只记在节点上,不在段上:它结束了就用节点上的 - const finished = state === 'done' || state === 'failed' + const finished = state === 'done' || state === 'failed' || state === 'rejected' const decided = taken.length ? taken : finished ? n.taken ?? [] : [] nodes[id] = { ...n, state, count: p?.count ?? 0, iteration: p?.iteration, taken: decided, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 49163c1d..742ac45a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1506,7 +1506,8 @@ export interface UpgradeResult { /** 单个节点在一次运行中的实时状态,驱动画布上的高亮。 */ export interface NodeRuntime { /** cancelled / suspended 是终态清扫时收的:运行被取消或服务重启时还没跑完的节点 */ - status: 'idle' | 'running' | 'done' | 'failed' | 'waiting' | 'skipped' | 'cancelled' | 'suspended' + /** rejected:这一步被人驳回(node.failed 带 reason=rejected,run/trace 的 NodeState 同名) */ + status: 'idle' | 'running' | 'done' | 'failed' | 'waiting' | 'skipped' | 'cancelled' | 'suspended' | 'rejected' durationMs?: number preview?: any error?: string diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 77af3970..dd165d1d 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2842,5 +2842,54 @@ await section('人的驳回 run.cancelled reason=rejected(05-已知遗留 (a) check('老事件(没有 reason)照旧「已取消」', legacy?.title === '已取消', legacy?.title) }) +await section('被驳回的那一步:node.failed 带 reason=rejected 时画「被驳回」,不画成失败;没有 reason 的老运行照旧失败(05 (a)、X9)', async () => { + const trace = await import(await load('/src/run/trace.ts')) + const derive = await import(await load('/src/run/derive.ts')) + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const start = [E(1, 'run.started', null, { nodes: 3 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' })] + const rejectedFail = E(5, 'node.failed', 'pay', { error: '「付款」未确认(被驳回),没有发送:金额不对', duration_ms: 1200, + reason: 'rejected', kind: 'action_confirm', decision: 'reject', actor: '张工', note: '金额不对' }) + const legacyFail = E(5, 'node.failed', 'pay', { error: '人工驳回:金额不对', duration_ms: 1200 }) + const end = E(6, 'run.cancelled', null, { reason: 'rejected', node_id: 'pay', label: '付款', kind: 'action_confirm', + decision: 'reject', actor: '张工', note: '金额不对' }) + const node = (events) => flatten(mod.decodeRun(events)).find((s) => s.kind === 'node' && s.nodeId === 'pay') + + const step = node([...start, rejectedFail, end]) + check('这一步写「被驳回」:状态 rejected,不是 failed、不是 error', step?.status === 'rejected' && step?.level !== 'error', + `${step?.status} / ${step?.level}`) + check('副标题写谁驳回、备注(步骤名就是节点名,不再写一遍)', step?.sub === '张工 驳回:金额不对', step?.sub) + check('原话放展开里,一字不改', step?.detail === '「付款」未确认(被驳回),没有发送:金额不对', step?.detail) + check('这一次执行也记成被驳回', step?.execs?.at(-1)?.status === 'rejected', step?.execs?.at(-1)?.status) + const expired = node([...start, { ...rejectedFail, data: { ...rejectedFail.data, decision: 'expired_reject', actor: null, + note: '超过时限,按设置驳回' } }, end]) + check('超时驳回:写按设置驳回,备注不重复', expired?.sub === '超过时限,按设置驳回', expired?.sub) + const legacy = node([...start, legacyFail, end]) + check('老运行(node.failed 没有 reason)照旧画失败', legacy?.status === 'failed' && legacy?.level === 'error', + `${legacy?.status} / ${legacy?.level}`) + + // 航迹:节点状态是 rejected,不是「失败于 X」,下游是「未到达」不是「被阻断」 + const graph = { nodes: [{ id: 'start', type: 'input' }, { id: 'pay', type: 'tool' }, { id: 'out', type: 'output' }], + edges: [{ source: 'start', target: 'pay' }, { source: 'pay', target: 'out' }] } + const fold = (events) => derive.applyDerived(events.reduce(trace.foldEvent, trace.emptyTrace()), graph) + // 航迹照真实的先后:停在确认上(run.interrupted)→ 人回复后恢复(run.resumed、重放的 node.started)→ 驳回 + const real = [E(1, 'run.started', null, { nodes: 3 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'run.interrupted', 'pay', { payload: { node_id: 'pay' } }), + E(5, 'run.resumed', null, {}), E(6, 'node.started', 'pay', { label: '付款', resumed: true }), + E(7, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' })] + const at = (e, seq) => ({ ...e, seq, ts: seq }) + const t = fold([...real, at(rejectedFail, 8), at(end, 9)]) + check('航迹:被驳回的节点状态是 rejected', t.nodes.pay?.state === 'rejected', t.nodes.pay?.state) + check('航迹:不记成失败的节点(没有「失败于 付款」)', t.failedNodeId === undefined, t.failedNodeId) + check('航迹:那一段也收成 rejected', t.nodes.pay?.segments?.at(-1)?.status === 'rejected', t.nodes.pay?.segments?.at(-1)?.status) + check('航迹:下游是「未到达」,不是「被阻断」', t.nodes.out?.state === 'unreached', t.nodes.out?.state) + check('航迹:原话留着(卡片悬停写得出谁、为什么)', t.nodes.pay?.error === '「付款」未确认(被驳回),没有发送:金额不对') + check('回放到驳回之后那一刻:投影也是 rejected', trace.project(t, 8500).nodes.pay?.state === 'rejected', + trace.project(t, 8500).nodes.pay?.state) + const old = fold([...real, at(legacyFail, 8), E(9, 'run.failed', null, { error: '人工驳回:金额不对', node_id: 'pay' })]) + check('老运行照旧:失败、失败于付款、下游被阻断', old.nodes.pay?.state === 'failed' && old.failedNodeId === 'pay' + && old.nodes.out?.state === 'blocked', `${old.nodes.pay?.state} / ${old.failedNodeId} / ${old.nodes.out?.state}`) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-run-states.mjs b/scripts/check-run-states.mjs index 798b5c08..64afc46f 100644 --- a/scripts/check-run-states.mjs +++ b/scripts/check-run-states.mjs @@ -1508,6 +1508,60 @@ await section('exits', async () => { } }) +// ---------------------------------------------------------------- 被驳回的那一步 + +await section('rejected', async () => { + // 驳回是人的决定,不是故障(05 (a)、总方案 X9):被驳回的那一步节点层仍发 node.failed,但带 reason=rejected。画布上它画成 + // 「被驳回」——中性色、停止的剪影,不是失败的三角、红框、抖动;下游是「未到达」,不是「被阻断」;运行胶囊的计数写被驳回。 + // 没有 reason 的老运行照旧画失败 + console.log('\n=== 被驳回的那一步 ===') + const REJECT_TAIL = [ + [3, 'human.resolved', 'human', { approved: false, note: '数字对不上', actor: '李工' }], + [0.05, 'run.resumed', null, { actor: '李工' }], + [0.05, 'node.started', 'human', { resumed: true }], + [0.1, 'node.failed', 'human', { duration_ms: 40, error: '人工驳回:数字对不上', reason: 'rejected', kind: 'approve', + decision: 'reject', actor: '李工', note: '数字对不上' }], + [0.05, 'run.cancelled', null, { reason: 'rejected', node_id: 'human', label: '班长审批', kind: 'approve', decision: 'reject', + actor: '李工', note: '数字对不上', timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }], + ] + const REJECTED = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, ...REJECT_TAIL]) + const LEGACY = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, + ...REJECT_TAIL.slice(0, 3), + [0.1, 'node.failed', 'human', { duration_ms: 40, error: '人工驳回:数字对不上' }], + [0.05, 'run.failed', null, { error: '人工驳回:数字对不上', node_id: 'human', timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }]]) + const { browser, page, errors } = await open({ theme: 'dark' }) + await setLod(page, 'full') + await feed(page, REJECTED(Date.now())) + await page.waitForTimeout(400) + const all = await cards(page) + check('被驳回的那一步:卡片状态是 rejected', all.human?.state === 'rejected', all.human?.state) + check('……剪影是停止(和取消同一个),不是失败的三角', all.human?.shape === 'stop', all.human?.shape) + check('……槽里写「被驳回」和原话的第一句', /被驳回/.test(all.human?.text ?? '') && /数字对不上/.test(all.human?.text ?? ''), + JSON.stringify(all.human?.text)) + check('……不写「失败」', !/失败/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text)) + const look = await page.evaluate(() => { + const card = document.querySelector('.react-flow__node[data-id="human"] .nc') + const note = card?.querySelector('.nc-tele-note') + return { failedClass: card?.classList.contains('node-failed'), rejectedClass: card?.classList.contains('node-rejected'), + noteTitle: note?.getAttribute('title') ?? '' } + }) + check('……不挂失败的红框(node-failed),挂 node-rejected', !look.failedClass && look.rejectedClass, JSON.stringify(look)) + check('……悬停写原话(谁驳回、为什么)', look.noteTitle.includes('数字对不上'), look.noteTitle) + check('下游是「未到达」,不是「被阻断」', all.out?.state === 'unreached', all.out?.state) + const failedId = await page.evaluate(() => window.__studio.getState().trace.failedNodeId ?? null) + check('航迹里没有「失败的节点」', failedId === null, String(failedId)) + check('运行收成 cancelled(不是 failed)', await page.evaluate(() => window.__studio.getState().runPhase) === 'cancelled') + shots.push(await shot(page, 'dark-rejected')) + + await feed(page, LEGACY(Date.now())) + await page.waitForTimeout(400) + const old = await cards(page) + check('老运行(node.failed 没有 reason)照旧画失败、下游被阻断', old.human?.state === 'failed' && old.out?.state === 'blocked', + `${old.human?.state} / ${old.out?.state}`) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + await browser.close() +}) + // ---------------------------------------------------------------- 关掉动效 await section('reduced', async () => {