活动伪函数占位:机器步落台账 + 一张验收单 —— 顺手揪出三个引擎 bug

按你定的形态做:占位一律机器步,每个活动只留一张验收单要人签;
**不碰活动内部的业务判断** —— 打标、定价、比价那些一概不做。

## 占位怎么占

一枚纯工具 `slot.placeholder`:只穿透上游给的值、给显眼的默认(数值一律 0,
主键给「PN-占位」这类),行上写 `status: '占位'`。它不判断任何事。
骨架 = N 个机器步各落一行台账 + 末步一张验收单。
验收单的卡面要人填的正是**该活动该产出、而占位产不出的判断**(如两枚去向旗)——
人类步的回传值同样经 writeOutput 落台账、步序在后,所以人填的覆盖占位的默认。
占位期要走哪个分支,在验收时说,不必改代码。

六个槽位一律 flow 范式(A2/A3/A6 本该是 loop,但 loop 要 Agent 真调模型;
占位是固定序列,声明成 flow 才诚实),那份没人用的 loop 骨架顺手删了。
预算改成固定 humanCalls: 2(机器步不吃人工调用)。

## 三个引擎 bug,都是「无声卡住」

**① 异或汇合在等一条永远不来的分支。** `joinReady` 按并行汇合写(全部责任分支收敛才放行),
可 G2J 是异或 —— 两条分支互斥、结构上只来一条。于是「不需要寻源」这条路上永远在等 A3。
最坑的是**连死路事件都没有**:边是选中了的,只在汇合判定里被 continue 跳过,
从外面看就是 A1 办结后什么都不再发生。已改为异或汇合不等,加了三条测试。

**② 出口载荷取的是落地前的草稿值,不是落地事实。** `releaseWrite` 直接回 `draft.patch`。
人的回传值天生是字符串 `"true"`,列是 boolean,PG 存的时候转了,可发出去的口里还是字符串
—— 下游活动入参校验当场拒收「/needSourcing must be boolean」,节点报 blocked,
而台账里明明是对的。改为合并后**读回来**:出口载荷与台账逐字一致,类型由列定义说了算。

**③ 审批箱按岗位码筛,无视工作组绑定。** 引擎派审批时「绑定先于策略」(ADR-011),
装了组的单实际派给被绑定的人;而收件与裁决两处都只比 `me.position.code` ——
于是「审批派给了我、我的审批箱里没有」,单子谁也看不见,流永远卡在审批点(实测)。
`toChain` 早为同一件事修过一次,这是漏的两处,一并按派单口径改。

## 顺带

`cards.issue` 的验收分支写死 `fields: []`,而 `acceptanceWorkOrderSchema` 本来就有 `fields`
—— 是没填,不是设计上不要。执行与验收现在共用同一份字段解析。

我自己也踩了一个:验收步的出参映射键写成驼峰 `needSourcing`,但**键是台账列名**
(小写蛇形),draftWrite 抛「非法标识符」,而且是在放行之后抛 —— 任务卡在 running、
台账一行没有、推进器一秒一次无限重试。测试里加了「所有出参键必须匹配 ^[a-z][a-z0-9_]*$」。

## 实测走到哪

起单 → A1(两机器步落台账,一张验收单)→ 人填 needAlternative=false →
G1 判「不需要选型」→ G2 判「不需要寻源」→ **G2J 通过** → A4 成本定价办结 →
A5 审批点(审批箱可见)→ 裁决通过 → **G3 判到 A6**(链位 6/7)。
A6 的三个去向要选择组(T2),到那儿为止。

typecheck ✓ lint ✓ depcruise 374 模块 ✓ api:check ✓ 498 测试 ✓

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 13:16:17 +08:00
co-authored by Claude Opus 5
parent b2f5f69d6a
commit 58d914b8a2
9 changed files with 487 additions and 114 deletions
@@ -7,16 +7,51 @@ import { describe, expect, it } from 'vitest';
import { MVP_SLOTS, SLOT_ARTIFACT } from '../seed-mvp-slots.ts';
describe('MVP_SLOTS', () => {
it('六槽位,id 与范式如 MVP(A5 审批点内置,不入库)', () => {
/*
* 占位期六个槽位**一律 flow**。A2/A3/A6 本该是案例活动(loop),
* 但 loop 由 Agent 每拍决策、要真调模型;占位本身是固定序列,声明成 flow 才诚实。
* 真开发它们为案例活动时,以新包版本装回 loop —— 那时这条断言要跟着改。
*/
it('六槽位,占位期一律 flow(A5 审批点内置,不入库)', () => {
const byId = new Map(MVP_SLOTS.map((m) => [m.identity.activityId as string, m.identity.paradigm]));
expect(MVP_SLOTS).toHaveLength(6);
expect(byId.get('rfq-intake')).toBe('flow');
expect(byId.get('alt-selection')).toBe('loop');
expect(byId.get('sourcing-inquiry')).toBe('loop');
expect(byId.get('cost-pricing')).toBe('flow');
for (const id of ['rfq-intake', 'alt-selection', 'sourcing-inquiry', 'cost-pricing', 'quote-negotiation', 'intel-archive']) {
expect(byId.get(id), id).toBe('flow');
}
expect(byId.has('pricing-approval')).toBe(false);
expect(byId.get('quote-negotiation')).toBe('loop');
expect(byId.get('intel-archive')).toBe('flow');
});
/*
* 占位骨架的形状:**机器步落台账 + 末步一张验收单**。三条都是踩过坑才立的:
* · 不写台账 → 发不出口 → 网关判不了 → 流停在第一个活动
* · 末步不是验收 → 活动永远不 release → 「等人却没东西给人答」
* · 出参映射的键必须是**台账列名**(小写蛇形)—— 写成驼峰会在放行后抛「非法标识符」
*/
it('占位骨架:机器步全是 pure-tool,末步是验收,出参键是台账列名', () => {
for (const m of MVP_SLOTS) {
const model = m.assets.businessModel as {
paradigm: string;
steps: ReadonlyArray<{ kind: string; workOrder?: string; output?: { fields: Record<string, string> } }>;
};
expect(model.paradigm, m.identity.activityId).toBe('flow');
const steps = model.steps;
expect(steps.length, m.identity.activityId).toBeGreaterThanOrEqual(2);
const last = steps[steps.length - 1]!;
expect(last.kind, `${m.identity.activityId} 末步该是人类步`).toBe('human');
expect(last.workOrder, `${m.identity.activityId} 末步该是验收单`).toBe('acceptance');
for (const s of steps.slice(0, -1)) {
expect(s.kind, `${m.identity.activityId} 机器步该是纯工具`).toBe('pure-tool');
expect(s.output, `${m.identity.activityId} 机器步该落一行台账`).toBeDefined();
}
// 所有出参映射的键都必须是小写蛇形(台账标识符闸:^[a-z][a-z0-9_]*$)
for (const s of steps) {
for (const col of Object.keys(s.output?.fields ?? {})) {
expect(col, `${m.identity.activityId} 的出参键 ${col} 不是台账列名`).toMatch(/^[a-z][a-z0-9_]*$/);
}
}
}
});
it('A1 出口带两枚去向旗;A2 透传 needSourcing —— 网关条件的数据来源', () => {