手工走通整条流,揪出五处编排层问题 —— 最要命的是假成功
把 `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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user