forked from ChaiHao/BPOS-MVP
手工走通整条流,揪出五处编排层问题 —— 最要命的是假成功
把 `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:
@@ -24,22 +24,70 @@ export const SLOT_ARTIFACT = [
|
||||
|
||||
const checksum = createHash('sha256').update(SLOT_ARTIFACT).digest('hex');
|
||||
|
||||
/** flow 槽位的业务模型骨架:MVP 作业指导书的步骤标题 —— 未实现期一步一张执行工单,人工可兜 */
|
||||
const flowSkeleton = (steps: readonly string[]) => ({
|
||||
paradigm: 'flow' as const,
|
||||
steps: steps.map((label, i) => ({
|
||||
/**
|
||||
* flow 槽位的业务模型骨架:MVP 作业指导书的步骤标题 —— 未实现期一步一张执行工单,人工可兜。
|
||||
*
|
||||
* 卡面**必须自述**。空卡(`card: {}`)会让工单落回默认的「请处理」+ 一个「结果」框:
|
||||
* 七个活动派出七张一模一样的卡,收卡的人不知道自己走到哪一步、这一步要干什么 ——
|
||||
* 整条流就没法手工走通,而手工走通恰恰是逐个开发活动之前最该做的验证。
|
||||
* 所以这里把步骤标签、第几步、以及「实现待开发、先人工兜」的实情都摆到卡面上。
|
||||
* 认的键见 `wiring/engines.ts` 的 `cards.issue`:`title` / `ask` / `fields` / `basis`。
|
||||
*
|
||||
* **末步必须是验收工单**,否则活动永远收不了口。`flow-runner` 写死了这条:
|
||||
* `released = lastStepIsAcceptance(steps) && lastVerdictPassed(position)` ——
|
||||
* 六步全写成 execution 时,人把六张单都答完,任务却停在 `awaiting-human`:
|
||||
* 没有待答工单、没有待答卡,**等人却没东西给人答**,必交出口也发不出去,
|
||||
* 下游网关判不了、整条流走不出 A1(实测)。
|
||||
*
|
||||
* 声明的步骤来自 MVP 作业指导书,不篡改它的语义 —— 所以在其后**追加**一步收口验收:
|
||||
* 放行即活动办结、按出口选择发射;驳回即退回重做。
|
||||
*/
|
||||
const flowSkeleton = (steps: readonly string[]) => {
|
||||
const total = steps.length + 1;
|
||||
const failure = { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' as const } };
|
||||
const source = { k: '来源', v: '活动槽位的步序声明(实现待开发)' };
|
||||
|
||||
const work = steps.map((label, i) => ({
|
||||
name: `S${i + 1}`,
|
||||
label,
|
||||
input: {},
|
||||
kind: 'human' as const,
|
||||
workOrder: 'execution' as const,
|
||||
card: {},
|
||||
card: {
|
||||
title: label,
|
||||
ask: `这一步的自动化还没开发,先由你来办:${label}。办完把结果填回来,流程照常往下走。`,
|
||||
basis: [{ k: '步序', v: `第 ${i + 1} 步 / 共 ${total} 步` }, source],
|
||||
},
|
||||
deadline: 'P1D',
|
||||
// 失败策略声明给定,不临场:人步失败即受阻待人 —— 槽位期没有可重试的机器步
|
||||
failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' as const } },
|
||||
})),
|
||||
failure,
|
||||
}));
|
||||
|
||||
return {
|
||||
paradigm: 'flow' as const,
|
||||
steps: [
|
||||
...work,
|
||||
{
|
||||
name: `S${total}`,
|
||||
label: '收口验收',
|
||||
input: {},
|
||||
kind: 'human' as const,
|
||||
workOrder: 'acceptance' as const,
|
||||
card: {
|
||||
title: '收口验收',
|
||||
basis: [
|
||||
{ k: '步序', v: `第 ${total} 步 / 共 ${total} 步(收口)` },
|
||||
{ k: '前置', v: `${steps.length} 步已由人工代办:${steps.join(' · ')}` },
|
||||
source,
|
||||
],
|
||||
},
|
||||
deadline: 'P1D',
|
||||
failure,
|
||||
},
|
||||
],
|
||||
parallelGroups: [],
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/** loop 槽位:CMMN 骨架(MVP 案例图的项)+ 叙事一句话;DMN 待实现期再挂 */
|
||||
const loopSkeleton = (items: readonly string[], narrative: string) => ({
|
||||
@@ -52,7 +100,22 @@ const loopSkeleton = (items: readonly string[], narrative: string) => ({
|
||||
const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') => ({
|
||||
activityId,
|
||||
name,
|
||||
packageVersion: '0.1.0',
|
||||
/*
|
||||
* 包版本 —— 改了槽位携带的东西就必须往上走。
|
||||
*
|
||||
* 「安装恒为新增、版本并存」是硬不变量:同 (activityId, version) 再装是 no-op,
|
||||
* 库里的 manifest 一个字都不会动。所以 0.1.0 期间把卡面从空卡改成自述卡,
|
||||
* 重播种会「静默成功」—— 打印「已装入」,库里还是旧的(实测踩到)。
|
||||
*
|
||||
* 0.2.0:步骤卡面自述(标题 / 要你答 / 步序依据)。
|
||||
* 0.3.0:人工调用预算按各槽位自己的步数给 —— 共用 3 会让 6 步的 A1 走到第 3 步就
|
||||
* `exhausted` 终态、结构上永远走不完(实测)。授权面申报值同源。
|
||||
* 0.4.0:flow 骨架末步追加**收口验收**—— 没有它活动永远不 release,
|
||||
* 六步答完就卡在「等人却没东西给人答」(实测)。
|
||||
* 契约的入参出口一直没动,故 `contract.version` 始终 0.1.0。
|
||||
* 在途任务按启动时版本跑完,不受影响。
|
||||
*/
|
||||
packageVersion: '0.4.0',
|
||||
category: '统一报价',
|
||||
paradigm,
|
||||
sdkVersion: '0.1.0-m5',
|
||||
@@ -64,7 +127,46 @@ const harness = { memoryWindow: 20, stallAfter: 'PT45M', nudge: [] };
|
||||
/** 七件契约里逐槽位相同的空段 —— 槽位期没有必交付物/验收细则,实现期再补 */
|
||||
const contractRest = { requiredDeliverables: [], acceptanceCriteria: [], prohibitions: [] };
|
||||
|
||||
const budget = { steps: 40, tokens: 200_000, activeMinutes: 60, humanCalls: 3 };
|
||||
/**
|
||||
* 各槽位的步/项清单 —— **预算与骨架共用同一个数组**,数不上不会漂移。
|
||||
* flow 是声明式人类步(一步一张工单);loop 的项由 Agent 临场推进,
|
||||
* 最坏情况一项问一次人,所以也按项数给预算。
|
||||
*/
|
||||
const STEPS = {
|
||||
rfqIntake: ['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单'],
|
||||
altSelection: ['替代对标表', '初筛清单'],
|
||||
sourcingInquiry: ['寻源比价表', '供应商回价单'],
|
||||
costPricing: ['取价基准', '逐条定价'],
|
||||
quoteNegotiation: ['正式报价单'],
|
||||
intelArchive: ['结果登记', '画像更新', '落地率统计'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 预算 —— `humanCalls` 必须**够这个槽位自己的人类步数**,不能六个槽位共用一个数。
|
||||
*
|
||||
* 曾经共用 `humanCalls: 3`,而 A1 声明了 6 个人类步:手工走到第 3 步就 `exhausted`
|
||||
* 终态、工作流转 abnormal,活动**结构上永远走不完**(实测:used 3 / cap 3,停在 A1)。
|
||||
* 槽位期是一步一张工单,所以按步数给:声明的 n 步 + 1 张收口验收 + 1 次余量。
|
||||
* 实现期活动自己声明真实预算时,这条约束不变:实际要问人的次数 ≤ humanCalls。
|
||||
*/
|
||||
const budgetFor = (declaredSteps: number) => ({
|
||||
steps: 40,
|
||||
tokens: 200_000,
|
||||
activeMinutes: 60,
|
||||
// n 张执行 + 1 张验收(flowSkeleton 追加的收口步)+ 1 次余量
|
||||
humanCalls: declaredSteps + 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* 授权面申报的人工调用预算 —— 与运行时真正吃的那个数**同源**。
|
||||
* 运行时只认 `resources.budget.humanCalls`(见 `wiring/engines.ts` 的 `caps`);
|
||||
* `authorization.humanCallBudget` 只给治理台显示。两者不一致,治理台就会摆一个假数。
|
||||
*/
|
||||
const authorizationFor = (humanSteps: number) => ({
|
||||
discretion: [],
|
||||
preauthorizedActions: [],
|
||||
humanCallBudget: budgetFor(humanSteps).humanCalls,
|
||||
});
|
||||
|
||||
export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
/* ── A1 客户RFQ受理(SOP · BD) ──────────────────────────────────── */
|
||||
@@ -108,8 +210,8 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget },
|
||||
authorization: authorizationFor(STEPS.rfqIntake.length),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor(STEPS.rfqIntake.length) },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
@@ -127,7 +229,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
]),
|
||||
},
|
||||
assets: {
|
||||
businessModel: flowSkeleton(['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单']),
|
||||
businessModel: flowSkeleton(STEPS.rfqIntake),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
@@ -178,8 +280,8 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget },
|
||||
authorization: authorizationFor(STEPS.altSelection.length),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget: budgetFor(STEPS.altSelection.length) },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
@@ -190,7 +292,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
},
|
||||
assets: {
|
||||
businessModel: loopSkeleton(
|
||||
['替代对标表', '初筛清单'],
|
||||
STEPS.altSelection,
|
||||
'MVP §1.5:上接 G1 是否需要选型;齐了即收 —— 必做项完成且无进行中项即结案,办结经 G2 定去向。',
|
||||
),
|
||||
skills: [],
|
||||
@@ -243,13 +345,13 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
authorization: authorizationFor(STEPS.sourcingInquiry.length),
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
skills: [],
|
||||
models: [],
|
||||
roles: ['PRM'],
|
||||
budget,
|
||||
budget: budgetFor(STEPS.sourcingInquiry.length),
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
@@ -266,7 +368,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
},
|
||||
assets: {
|
||||
businessModel: loopSkeleton(
|
||||
['寻源比价表', '供应商回价单'],
|
||||
STEPS.sourcingInquiry,
|
||||
'MVP §1.7:上接 G2 是否需要寻源;齐了即收 —— 必做项完成且无进行中项即结案,办结转 A4 成本定价。',
|
||||
),
|
||||
skills: [],
|
||||
@@ -318,8 +420,8 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget },
|
||||
authorization: authorizationFor(STEPS.costPricing.length),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor(STEPS.costPricing.length) },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
@@ -329,7 +431,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.quote_draft']),
|
||||
},
|
||||
assets: {
|
||||
businessModel: flowSkeleton(['取价基准', '逐条定价']),
|
||||
businessModel: flowSkeleton(STEPS.costPricing),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
@@ -379,13 +481,13 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
authorization: authorizationFor(STEPS.quoteNegotiation.length),
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
skills: [],
|
||||
models: [],
|
||||
roles: ['BD'],
|
||||
budget,
|
||||
budget: budgetFor(STEPS.quoteNegotiation.length),
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
@@ -397,7 +499,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
},
|
||||
assets: {
|
||||
businessModel: loopSkeleton(
|
||||
['正式报价单'],
|
||||
STEPS.quoteNegotiation,
|
||||
'MVP §1.4:上接审批通过进入;发出标准报价并跟谈,办结经 G4 议价结果定去向(成交 / 有异议 / 拒绝或超时)。',
|
||||
),
|
||||
skills: [],
|
||||
@@ -449,8 +551,8 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 3 },
|
||||
resources: { tools: [], skills: [], models: [], roles: ['BD'], budget },
|
||||
authorization: authorizationFor(STEPS.intelArchive.length),
|
||||
resources: { tools: [], skills: [], models: [], roles: ['BD'], budget: budgetFor(STEPS.intelArchive.length) },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
@@ -460,7 +562,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.landing_stats']),
|
||||
},
|
||||
assets: {
|
||||
businessModel: flowSkeleton(['结果登记', '画像更新', '落地率统计']),
|
||||
businessModel: flowSkeleton(STEPS.intelArchive),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
@@ -479,10 +581,23 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
|
||||
export async function installSlots(container: Container): Promise<void> {
|
||||
for (const manifest of MVP_SLOTS) {
|
||||
const { activityId, packageVersion, name, paradigm } = manifest.identity;
|
||||
|
||||
/*
|
||||
* 先问库里有没有这一版 —— 因为「安装恒为新增、版本并存」:同版再装是 no-op,
|
||||
* manifest 一个字不动。不问就报「已装入」等于**撒谎**:改了槽位内容重播种,
|
||||
* 屏幕说装好了,库里还是旧的,人拿着旧行为查新代码(实测踩到,那次是卡面没换)。
|
||||
*/
|
||||
const already = await container.registry
|
||||
.manifestOf(activityId, packageVersion)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const verdict = await container.registry.install(manifest, SLOT_ARTIFACT);
|
||||
const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error');
|
||||
process.stdout.write(
|
||||
` 槽位 ${manifest.identity.name}(${manifest.identity.paradigm})静态审 ${verdict.staticFindings.length} 条` +
|
||||
` 槽位 ${name}(${paradigm})${already ? ` v${packageVersion} 已在库,未改动 —— 要换内容请升包版` : ` v${packageVersion} 已装`}` +
|
||||
` · 静态审 ${verdict.staticFindings.length} 条` +
|
||||
(errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') +
|
||||
'\n',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+37
-2
@@ -77,8 +77,12 @@
|
||||
## 活动槽位未实现(2026-07-28)
|
||||
|
||||
六个槽位(rfq-intake / alt-selection / sourcing-inquiry / cost-pricing / quote-negotiation /
|
||||
intel-archive)只有契约面,入口产物是抛「未实现」的占位。起单后任务会停在 A1,这是**诚实状态**,
|
||||
不是缺陷 —— 底座备齐后按 A1→A7 顺序逐个开发,届时以新包版本经安装通道换入,槽位契约不动。
|
||||
intel-archive)只有契约面,入口产物是抛「未实现」的占位 —— 底座备齐后按 A1→A7 顺序逐个开发,
|
||||
届时以新包版本经安装通道换入,槽位契约不动。
|
||||
|
||||
2026-07-28 更正:这条原写「起单后任务会停在 A1」,手工走查后发现说得不准 ——
|
||||
**A1 本身走得完**(六步人工代办 + 收口验收放行,活动 `released`),卡的是**它后面的 G1**:
|
||||
出口的必填去向旗产不出来,网关判不了。准确的诚实状态见下文「编排层 · 手工走查」。
|
||||
|
||||
## 编排层 · 走查已清项(2026-07-28)
|
||||
|
||||
@@ -93,3 +97,34 @@ intel-archive)只有契约面,入口产物是抛「未实现」的占位。起
|
||||
|
||||
已验为**正常**、不需要改的:编辑器往返无损(16 边 14 节点、映射与驳回声明全留)、
|
||||
边映射校验逐边点名、发布门硬(缺条件即 412)、调起规则四类齐、导出依赖文件内容完整。
|
||||
|
||||
## 编排层 · 手工走查(2026-07-28)
|
||||
|
||||
把 `unified-quote` 从起单一路手工答到底,发现并已修 **5 项**:
|
||||
|
||||
| 症状 | 根因 | 修法 |
|
||||
|---|---|---|
|
||||
| 流程链读作 A1→A7→A2→A6→A3 | `toChain` 按 `layout.col` 排,而 MVP 图是回形走线 | 新增 `advanceOrder`,按边拓扑排、排除回边 |
|
||||
| 七个活动派出七张一样的「请处理」 | 槽位骨架 `card: {}`,工单落回默认 | 卡面自述:标题 = 步骤标签,依据带步序 |
|
||||
| 走到第 3 步就 `exhausted` 终态 | 六槽位共用 `humanCalls: 3`,A1 有 6 步 | `budgetFor(n)` 按各自步数给 |
|
||||
| 六步答完卡在「等人却没东西给人答」 | 末步全是 execution,而 `released` 只出自末步验收放行 | 骨架末尾追加一步「收口验收」 |
|
||||
| 只走了 A1 却报 `completed` | 状态聚合把「没有在跑的了」当完成 | 「完成」凭到过终点;收敛未到终点 = `abnormal` |
|
||||
|
||||
**当前诚实状态**:A1 六步可人工代办、收口验收可放行、活动 `released`;但流**过不了 G1** ——
|
||||
出口 `intake` 的必填去向旗(`needAlternative` / `needSourcing`)产不出来。这不是缺陷:
|
||||
出口载荷由**台账落地事实**推导(数组←落地行、标量←铺平事实),槽位没实现台账写入就没有事实,
|
||||
让它凭空捏一个就是在系统内部 mock,规矩不许。现在这个状态会**如实上报**:
|
||||
工作流 `abnormal` + 一条 `route-dead-end` 事件写明「G1 的两条出边要 needAlternative,
|
||||
当时可用的键只有 body / customerCode / records / rfqRef / subject」。
|
||||
|
||||
**于是 A1 的开发完成判据就具体了**:`rfq-intake` 必须落 `project.intake_record` 等行、
|
||||
并让 `needAlternative` / `needSourcing` 成为落地事实 —— 否则 G1 永远判不了。A2…A7 同理:
|
||||
先看自己出口的必填字段能不能由落地事实推出来。
|
||||
|
||||
## api:check 的盲点(2026-07-28)
|
||||
|
||||
契约面只收 `index.d.ts` / `contract.d.ts` / `port.d.ts`,而 `index.d.ts` 是 re-export **名单**。
|
||||
所以**给纯函数加形参、改返回类型,这道闸看不见** —— 实测:`aggregateWorkflowState` 加了
|
||||
第三个入参,api:check 报「公开契约无变化 ✔」。名单变化(增删导出)照样能拦住。
|
||||
要补得让契约面也收 domain 的 .d.ts,代价是全部纯函数签名进快照、日常改动噪声变大;
|
||||
在那之前:**改纯函数签名时手动同步 CONTRACT.md**,别指望闸提醒。
|
||||
|
||||
Reference in New Issue
Block a user