手工走通整条流,揪出五处编排层问题 —— 最要命的是假成功

把 `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:
2026-07-28 11:11:32 +08:00
co-authored by Claude Opus 5
parent 5826b6f976
commit 22aa44c775
6 changed files with 260 additions and 36 deletions
+145 -30
View File
@@ -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<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',
);