From 9201f6b0945fa87205d4592b4fb4e9c5e137f6dc Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 05:03:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A4=E6=8D=AE=20=E2=91=A5=20=E8=A7=A3?= =?UTF-8?q?=E9=94=81:=E5=AE=B9=E5=BF=8D=E5=88=86=E6=94=AF=E6=8E=A8?= =?UTF-8?q?=E5=BE=97=E5=8A=A8=E6=B1=87=E5=90=88,=E6=B1=87=E5=90=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=AD=89=E4=B8=80=E4=B8=AA=E6=B0=B8=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E6=9D=A5=E7=9A=84=E7=BD=91=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处修: ① `joinReady` 把**网关直连汇合的直通道**也算作一票要等的分支。 网关不是责任单元、永远不会有实例,于是汇合永远等不齐,整条流卡死。 改为:来源是网关的入边不参与等待 —— 分叉一发生它就算过了。 (统一报价图里这条直通道就是 TYPE-A 快路。)加了回归守卫测试。 ② 容忍(tolerate)只把实例标成「已消化」就收手,不往下推 —— `joinReady` 认 tolerated,可没人再去问一次汇合够不够齐。改为标完即续推。 ③ 蒸馏钩子失败会把已落定的终态掀翻(治理台终止返回 500,任务卡在中间态)。 蒸馏是进化管线的入口、不是终态的一部分:失败就失败,终态照落。 失败也不再往任务记忆里追加 —— 此刻记忆已封卷,那个守卫拒得对。 结果:A1 released → G2 分叉 → A3 / A2 两个 loop 起在正确工位 → 按声明容忍收束 → **汇合回齐 → A4 成本定价起到冯兵工位**。 gates: tsc / eslint / depcruise / 397 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .data/attachments/inbound/rfq-050@x.com.eml | 12 +++++ .data/attachments/inbound/rfq-060@x.com.eml | 12 +++++ .../modules/process/__tests__/closure.test.ts | 48 +++++++++++++++++++ .../process/internal/domain/routing.ts | 12 ++++- .../internal/usecase/process-service.ts | 10 ++++ .../task/internal/usecase/task-service.ts | 23 ++++++--- 6 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 .data/attachments/inbound/rfq-050@x.com.eml create mode 100644 .data/attachments/inbound/rfq-060@x.com.eml diff --git a/.data/attachments/inbound/rfq-050@x.com.eml b/.data/attachments/inbound/rfq-050@x.com.eml new file mode 100644 index 0000000..1d931f6 --- /dev/null +++ b/.data/attachments/inbound/rfq-050@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-050@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C040 RFQ-20260727R 求报价", + "body": "客户编码 C040,询价单号 RFQ-20260727R。\nPN-NEW-888 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T17:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-060@x.com.eml b/.data/attachments/inbound/rfq-060@x.com.eml new file mode 100644 index 0000000..5f656ab --- /dev/null +++ b/.data/attachments/inbound/rfq-060@x.com.eml @@ -0,0 +1,12 @@ +{ + "messageId": "rfq-060@x.com", + "from": "buyer@x.com", + "to": [ + "rfq@we.com" + ], + "cc": [], + "subject": "【询价】客户 C050 RFQ-20260727S 求报价", + "body": "客户编码 C050,询价单号 RFQ-20260727S。\nPN-NEW-999 TI 年用量 9000 目标价 6.00 交期 10w", + "receivedAt": "2026-07-27T18:00:00.000Z", + "attachments": [] +} \ No newline at end of file diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index 75d1327..de37547 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -1,6 +1,8 @@ /** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ import { describe, expect, it } from 'vitest'; +import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; +import { joinReady } from '../internal/domain/routing.ts'; import { checkClosure } from '../internal/domain/closure.ts'; import type { ActivityContractFace } from '../internal/domain/closure.ts'; import { @@ -291,3 +293,49 @@ describe('失败走向的消化与重开', () => { expect(nextInstanceNumber(instances, 'A1')).toBe(1); }); }); + +describe('joinReady —— 网关直通道不参与等待', () => { + const pkg = processPackageSchema.parse({ + id: 'p-join', + name: '汇合测试', + version: 1, + state: 'published', + callInterface: { input: { type: 'object' }, output: { type: 'object' } }, + nodes: [ + { kind: 'start', id: 'S', label: '开始' }, + { kind: 'gateway', id: 'F', label: '分叉', gateway: 'parallel', mode: 'fork' }, + { kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } }, + { kind: 'activity', id: 'Y', label: 'Y', activityId: 'a-y', owner: { kind: 'position', position: 'BD' } }, + { kind: 'gateway', id: 'J', label: '汇合', gateway: 'parallel', mode: 'join' }, + { kind: 'end', id: 'E', label: '结束' }, + ], + edges: [ + { id: 'e1', from: 'S', to: 'F', mapping: [], reopen: false }, + { id: 'e2', from: 'F', to: 'X', mapping: [], reopen: false }, + { id: 'e3', from: 'F', to: 'Y', mapping: [], reopen: false }, + // 分叉直连汇合的**直通道**(TYPE-A 快路):来源是网关,永远不会有实例 + { id: 'e4', from: 'F', to: 'J', mapping: [], reopen: false }, + { id: 'e5', from: 'X', to: 'J', mapping: [], reopen: false }, + { id: 'e6', from: 'Y', to: 'J', mapping: [], reopen: false }, + { id: 'e7', from: 'J', to: 'E', mapping: [], reopen: false }, + ], + variables: [], + }); + + it('两条责任分支都收敛 → 回齐(网关直通道不算一票)', () => { + expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).toBe(true); + }); + + it('一条容忍消化、一条收敛 → 回齐', () => { + expect(joinReady(pkg, 'J', new Set(['X']), new Set(['Y']))).toBe(true); + }); + + it('还差一条 → 不回齐', () => { + expect(joinReady(pkg, 'J', new Set(['X']), new Set())).toBe(false); + }); + + it('把网关来源也算进去就会永远等不到 —— 这是回归守卫', () => { + // 若实现改回「全部入边都要收敛」,上面第一条断言必然转 false + expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).not.toBe(false); + }); +}); diff --git a/apps/api/src/modules/process/internal/domain/routing.ts b/apps/api/src/modules/process/internal/domain/routing.ts index f7ac808..6411f53 100644 --- a/apps/api/src/modules/process/internal/domain/routing.ts +++ b/apps/api/src/modules/process/internal/domain/routing.ts @@ -87,14 +87,22 @@ export function selectEdges( return outgoing; } -/** AND 汇合是否回齐:全部入边的来源分支都已收敛 */ +/** + * AND 汇合是否回齐:全部**责任分支**的来源都已收敛。 + * + * 网关本身不是责任单元、永远不会有实例,所以来源是网关的入边(分叉直连汇合的**直通道**) + * 不参与等待 —— 分叉一发生它就算过了。若把它也算进去,汇合会永远等一个不可能到来的 + * 「网关收敛」,整条流卡死在这里。 + */ export function joinReady( pkg: ProcessPackage, joinNodeId: string, converged: ReadonlySet, tolerated: ReadonlySet, ): boolean { - const inbound = pkg.edges.filter((e) => e.to === joinNodeId); + const isGateway = (nodeId: string): boolean => + pkg.nodes.find((n) => n.id === nodeId)?.kind === 'gateway'; + const inbound = pkg.edges.filter((e) => e.to === joinNodeId && !isGateway(e.from)); // 容忍分支以「已消化」计,不阻汇合 return inbound.every((e) => converged.has(e.from) || tolerated.has(e.from)); } 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 f0587a9..98bb94c 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -337,6 +337,16 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { if (target !== undefined) await enter(record, pkg, target, { __terminal: terminal, __from: nodeId }); } + if (disposition.kind === 'tolerate') { + /* + * 容忍 = 这条支线的失败被吸收,**流继续往下走**。 + * 只把实例标成「已消化」而不往下推,汇合就永远等不到它: + * `joinReady` 认 tolerated,可没人再去问一次它够不够齐。 + * 载荷为空 —— 容忍分支本就没有产出,下游的必填映射不得依赖它(闭合校验已拦)。 + */ + await advanceFrom(record, pkg, nodeId, {}); + } + return this.refreshState(workflowId); }, diff --git a/apps/api/src/modules/task/internal/usecase/task-service.ts b/apps/api/src/modules/task/internal/usecase/task-service.ts index a9dad71..92cd68b 100644 --- a/apps/api/src/modules/task/internal/usecase/task-service.ts +++ b/apps/api/src/modules/task/internal/usecase/task-service.ts @@ -266,12 +266,23 @@ export function createTaskService(deps: TaskServiceDeps): TaskService { // 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目 await deps.memory.seal(taskId); - await deps.memory.distill({ - taskId, - activityId: task.activityId, - trigger: 'terminal', - prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`, - }); + try { + await deps.memory.distill({ + taskId, + activityId: task.activityId, + trigger: 'terminal', + prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`, + }); + } catch { + /* + * 蒸馏是**进化管线**的入口,不是终态的一部分:它产出的是待回放待放行的候选增量。 + * 它失败(模型不可达等)绝不能把已经落定的终态掀翻 —— 那会让任务卡在 + * 「实际已终、状态却没落」的中间态,治理台也看不到。 + * + * 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉 + * (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。 + */ + } return updated; },