From 22aa44c77555894be1a796abb843ec00555b885a Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 11:11:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=8B=E5=B7=A5=E8=B5=B0=E9=80=9A=E6=95=B4?= =?UTF-8?q?=E6=9D=A1=E6=B5=81,=E6=8F=AA=E5=87=BA=E4=BA=94=E5=A4=84?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E5=B1=82=E9=97=AE=E9=A2=98=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=9C=80=E8=A6=81=E5=91=BD=E7=9A=84=E6=98=AF?= =?UTF-8?q?=E5=81=87=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 `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) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- apps/api/src/app/seed-mvp-slots.ts | 175 +++++++++++++++--- apps/api/src/modules/process/CONTRACT.md | 6 +- .../modules/process/__tests__/closure.test.ts | 21 +++ .../internal/domain/owner-resolution.ts | 25 ++- .../internal/usecase/process-service.ts | 30 ++- docs/debt.md | 39 +++- 6 files changed, 260 insertions(+), 36 deletions(-) diff --git a/apps/api/src/app/seed-mvp-slots.ts b/apps/api/src/app/seed-mvp-slots.ts index 6990e35..1e03f41 100644 --- a/apps/api/src/app/seed-mvp-slots.ts +++ b/apps/api/src/app/seed-mvp-slots.ts @@ -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 } }, - })), - parallelGroups: [], -}); + 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 { 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', ); diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 352146f..d51e0e1 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -31,12 +31,16 @@ - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, 重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合 + - **「完成」凭到过终点节点**,不凭「没有在跑的了」:收敛但未到终点 = 卡住 = `abnormal`。 + `aggregateWorkflowState` 收第三个入参(包的终点 id 集);不传则退回旧口径,新代码一律传 - **失败走向的消化**:改道 / 容忍 / 升审批一经生效即消化;终止流待人工处置中算异常 - **流级重做一律重开新实例** —— 同节点、新任务 ID、留痕关联,不复活旧任务 其它不变量: - **流转零 LLM**:选边与条件求值全部经 `@sfeel`;判定依据入事件记录 - - **XOR 恰取一条**:条件命中优先,无条件的边作默认分支兜底 + - **XOR 恰取一条**:条件命中优先,无条件的边作默认分支兜底; + 一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件 + (哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值) - **AND 汇合要回齐**:未回齐就不进汇合节点;容忍分支以「已消化」计,不阻汇合 - **流程层不自动重试活动**(过程内重试归 Harness); 流级重做一律人裁决后**重开新任务实例** diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index de37547..523624a 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -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('失败走向的消化与重开', () => { diff --git a/apps/api/src/modules/process/internal/domain/owner-resolution.ts b/apps/api/src/modules/process/internal/domain/owner-resolution.ts index 9a8bb81..a4e0936 100644 --- a/apps/api/src/modules/process/internal/domain/owner-resolution.ts +++ b/apps/api/src/modules/process/internal/domain/owner-resolution.ts @@ -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) ────────────────────────────────────── */ diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index bde4d15..ff0aa9c 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -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, diff --git a/docs/debt.md b/docs/debt.md index afcc402..bc1b6be 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -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**,别指望闸提醒。