手工走通整条流,揪出五处编排层问题 —— 最要命的是假成功

把 `unified-quote` 从起单一路手工答到底(这是逐个开发活动之前该做的验证),
一路撞出五处,按撞到的顺序:

① 七个活动派出七张一模一样的「请处理」
   槽位骨架给的是 `card: {}`,工单标题/字段全落回默认。收卡的人不知道自己走到
   哪一步、这一步要干什么,整条流没法手工走通。→ 卡面自述:标题就是步骤标签,
   依据带「第几步 / 共几步」,并如实说「这一步的自动化还没开发,先由你来办」。

② 走到第 3 步就 `exhausted` 终态、工作流转 abnormal
   六个槽位共用 `humanCalls: 3`,而 A1 声明了 6 个人类步 —— 活动**结构上永远走不完**。
   → `budgetFor(n)` 按各槽位自己的步数给。步/项清单提成 `STEPS` 常量,
   预算与骨架共用同一个数组,数不上不会漂移。治理台申报值也同源(否则台上摆假数)。

③ 六步答完,任务卡在「等人,却没有任何东西等人答」
   `flow-runner` 写死了 `released = lastStepIsAcceptance && lastVerdictPassed` ——
   末步不是验收工单,活动就永远不 release。→ 声明的六步来自 MVP 作业指导书,
   不篡改语义,在其后**追加**一步「收口验收」。

④ 只走了 A1(一个节点实例)却报 `completed` ← **最危险的一处**
   状态聚合把「没有在跑的了」当成完成。界面上是一单已完成的报价,业务上一步没走。
   → 「完成」凭**到过终点节点**;收敛而未到终点 = 卡住 = `abnormal`,进驾驶舱异常卡。
   `aggregateWorkflowState` 收第三个入参(终点 id 集),不传退回旧口径。

⑤ 异或网关一条边都选不中时,流无声停住
   ④ 让它至少报异常了,但只说「有问题」不说「哪儿」。→ 发 `route-dead-end` 事件:
   哪个节点、各出边的条件、判据里当时有哪些键。只记键名不记值 —— 诊断够用,
   载荷不进事件明文。实测这条痕直接指出「G1 要 needAlternative,而键里没有」。

顺带:改槽位内容必须升包版(0.1.0 → 0.4.0)。「安装恒为新增、版本并存」是硬不变量,
同版重装是 no-op、manifest 一字不动 —— 我第一次改完重播种,屏幕报「已装入」而库里还是旧的。
`installSlots` 现在先问库、如实报「已装」还是「已在库,未改动 —— 要换内容请升包版」。

**走查后的诚实状态**:A1 走得完、活动 released;流**过不了 G1**,因为出口的必填去向旗
产不出来。这不是缺陷 —— 出口载荷由台账落地事实推导,槽位没实现台账写入就没有事实,
凭空捏一个就是在系统内部 mock。于是 A1 的开发完成判据也具体了:必须让
`needAlternative` / `needSourcing` 成为落地事实,否则 G1 永远判不了。债务簿已记。

另记一条闸的盲点:api:check 只快照 index/contract/port.d.ts,而 index 是 re-export 名单 ——
给纯函数加形参它看不见(本次实测)。改签名要手动同步 CONTRACT.md,别指望闸提醒。

typecheck ✓ lint ✓ depcruise 374 模块 ✓ api:check ✓ 490 测试 ✓(均以退出码验)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 11:11:32 +08:00
co-authored by Claude Opus 5
parent 5826b6f976
commit 22aa44c775
6 changed files with 260 additions and 36 deletions
+5 -1
View File
@@ -31,12 +31,16 @@
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合
- **「完成」凭到过终点节点**,不凭「没有在跑的了」:收敛但未到终点 = 卡住 = `abnormal`。
`aggregateWorkflowState` 收第三个入参(包的终点 id 集);不传则退回旧口径,新代码一律传
- **失败走向的消化**:改道 / 容忍 / 升审批一经生效即消化;终止流待人工处置中算异常
- **流级重做一律重开新实例** —— 同节点、新任务 ID、留痕关联,不复活旧任务
其它不变量:
- **流转零 LLM**:选边与条件求值全部经 `@sfeel`;判定依据入事件记录
- **XOR 恰取一条**:条件命中优先,无条件的边作默认分支兜底
- **XOR 恰取一条**:条件命中优先,无条件的边作默认分支兜底;
一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件
(哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)
- **AND 汇合要回齐**:未回齐就不进汇合节点;容忍分支以「已消化」计,不阻汇合
- **流程层不自动重试活动**(过程内重试归 Harness);
流级重做一律人裁决后**重开新任务实例**
@@ -273,6 +273,27 @@ describe('aggregateWorkflowState —— 聚合投影,流程层不另设状态机
it('控制事件终止 → 已终止', () => {
expect(aggregateWorkflowState([inst({ running: true })], true)).toBe('terminated');
});
/*
* 「完成」凭到过终点,不凭没有在跑的了。
* 实测过一次假成功:A1 办结、出口少了必填去向旗、G1 异或出边条件全不成立,
* 流无声停在 A1 —— 只有一个节点实例,状态却报 completed,界面上是一单「已完成」的报价。
*/
it('传了终点节点:到过终点才算完成', () => {
expect(
aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' }), inst({ nodeId: 'end', terminal: 'released' })], false, ['end']),
).toBe('completed');
});
it('传了终点节点:收敛了但没到终点 → 异常(卡住了,不是完成)', () => {
expect(aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' })], false, ['end'])).toBe('abnormal');
});
it('终点自己没 released 也不算完成', () => {
expect(
aggregateWorkflowState([inst({ nodeId: 'end', terminal: 'blocked', failureDigested: true })], false, ['end']),
).toBe('abnormal');
});
});
describe('失败走向的消化与重开', () => {
@@ -125,6 +125,12 @@ export type NodeInstance = {
export function aggregateWorkflowState(
instances: readonly NodeInstance[],
controlTerminated: boolean,
/**
* 流程包的终点节点 id —— **判「完成」必须凭它**。
*
* 不给(旧签名)时退回「收敛即完成」的老口径,只为不动既有调用;新代码一律传。
*/
endNodeIds?: readonly string[],
): WorkflowState {
if (controlTerminated) return 'terminated';
@@ -139,11 +145,26 @@ export function aggregateWorkflowState(
if (live.some((i) => i.running)) return 'running';
if (live.some((i) => i.waiting)) return 'waiting';
// 全部已激活的节点实例收敛(完成或已消化)且无在途实例 → 已完成
// 全部已激活的节点实例收敛(完成或已消化)且无在途实例
const allConverged = live.every(
(i) => i.terminal === 'released' || (i.terminal !== undefined && i.failureDigested === true),
);
return allConverged && live.length > 0 ? 'completed' : 'waiting';
if (!allConverged || live.length === 0) return 'waiting';
/*
* 收敛 ≠ 完成。**完成的判据是到过终点节点**,不是「没有在跑的了」。
*
* 实测:A1 办结后发出的 `intake` 口少了必填的两枚去向旗,G1 的异或出边条件
* 全不成立,流就无声停在 A1 —— 只有一个节点实例,状态却报 `completed`,
* 界面上是一单「已完成」的报价,业务上一步都没往下走。假成功比报错危险得多。
*
* 没到终点又无事可做,就是**卡住了**:报 `abnormal`,进驾驶舱的异常工作流卡,
* 让人看见并处置。`waiting` 也不对 —— 那是在等某个会到的东西,而这里什么都不会到。
*/
if (endNodeIds === undefined) return 'completed';
const ends = new Set(endNodeIds);
const reachedEnd = live.some((i) => ends.has(i.nodeId) && i.terminal === 'released');
return reachedEnd ? 'completed' : 'abnormal';
}
/* ── 失败走向的消化判定(OS 03) ────────────────────────────────────── */
@@ -184,6 +184,31 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const flat = { ...scope.initiation, ...scope.vars, ...payload };
const edges = selectEdges(pkg, fromNodeId, flat, deps.sfeel.condition);
/*
* 一条边都没选中,而这个节点确实有出边 —— 流到此为止,再没有谁会来推它。
* 这是**死路**,不是终点。异或网关最容易碰上:发布闸要求它的每条出边都带条件,
* 所以没有无条件兜底边可用,判据一缺就全不成立(实测:A1 的出口少了两枚去向旗,
* G1 一条分支都选不中,流无声停在 A1)。
*
* 状态聚合已经会把它报成 `abnormal`(没到终点就不算完成),但那只说「有问题」、
* 不说「哪儿有问题」。这里留一条可诊断的痕:哪个节点、出边各自的条件、判据里到底有哪些键。
* 只记键名不记值 —— 诊断够用,载荷不进事件明文。
*/
if (edges.length === 0) {
const outgoing = pkg.edges.filter((e) => e.from === fromNodeId);
if (outgoing.length > 0) {
await deps.events.publish({
workflowId: record.id,
kind: 'route-dead-end',
detail: {
nodeId: fromNodeId,
edges: outgoing.map((e) => ({ id: e.id, to: e.to, condition: e.condition ?? '(无条件)' })),
availableKeys: Object.keys(flat).sort(),
},
});
}
}
for (const edge of edges) {
const target = pkg.nodes.find((n) => n.id === edge.to);
if (target === undefined) continue;
@@ -424,7 +449,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const record = await requireWorkflow(workflowId);
if (record.state === 'terminated') return 'terminated';
const instances = await deps.store.listInstances(workflowId);
const state = aggregateWorkflowState(instances as NodeInstance[], false);
// 「完成」凭到过终点,不凭没有在跑的了 —— 所以要把包的终点节点交给聚合
const pkg = await deps.store.getPackage(record.packageId, record.packageVersion);
const endNodeIds = (pkg?.nodes ?? []).filter((n) => n.kind === 'end').map((n) => n.id);
const state = aggregateWorkflowState(instances as NodeInstance[], false, endNodeIds);
if (state !== record.state) {
await deps.store.updateWorkflow(workflowId, {
state,