This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts
T
xcsandClaude Opus 5 df18882322 脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 09:31:35 +08:00

447 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
*
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
*/
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
/** 案计划(整案)五态 */
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
export type CaseState = (typeof CASE_STATES)[number];
/** 计划项八态 */
export const ITEM_STATES = [
'Available',
'Enabled',
'Disabled',
'Active',
'Suspended',
'Failed',
'Completed',
'Terminated',
] as const;
export type ItemState = (typeof ITEM_STATES)[number];
/** 迁移拓扑 —— 引擎不得私改 */
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
Disabled: ['Enabled', 'Terminated'],
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
Suspended: ['Active', 'Terminated'],
Failed: ['Active', 'Terminated'],
Completed: [],
Terminated: [],
};
export class IllegalTransitionError extends Error {
constructor(
readonly from: string,
readonly to: string,
) {
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
this.name = 'IllegalTransitionError';
}
}
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
return ITEM_TRANSITIONS[from].includes(to);
}
export function assertItemTransition(from: ItemState, to: ItemState): void {
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
}
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
return CASE_TRANSITIONS[from].includes(to);
}
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
export type ItemRuntime = {
readonly id: string;
readonly state: ItemState;
/** repetitionRule 的实例序号 */
readonly instance: number;
/** 语义性前提待裁量评估的标记 */
readonly awaitingJudgement?: boolean;
/**
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
*
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
*/
readonly worldTouched?: boolean;
};
export type CaseRuntime = {
readonly state: CaseState;
readonly items: readonly ItemRuntime[];
/** 已达成的里程碑 */
readonly milestones: readonly string[];
};
export function initialRuntime(model: CaseModel): CaseRuntime {
return {
state: 'Active',
items: model.items.map((item) => ({
id: item.id,
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
state: item.discretionary ? 'Available' : 'Available',
instance: 1,
})),
milestones: [],
};
}
/* ── 前提求值 ───────────────────────────────────────────────────────── */
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
export type CriterionVerdict =
| { readonly kind: 'satisfied' }
| { readonly kind: 'unsatisfied' }
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
| { readonly kind: 'needs-judgement'; readonly statement: string };
export function evaluateCriterion(
criterion: Criterion,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): CriterionVerdict {
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
if (criterion.onParts.length > 0) {
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
if (!allArrived) return { kind: 'unsatisfied' };
}
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
}
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
export type EnabledEntry = {
readonly id: string;
readonly name: string;
readonly type: PlanItem['type'];
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
readonly requiresManualStart: boolean;
readonly discretionary: boolean;
};
export type Redlight = { readonly criterion: string; readonly statement: string };
export type EnabledSet = {
readonly entries: readonly EnabledEntry[];
/**
* **进行中的项** —— 已开工、还没交出成果的那些。
*
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
*
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
*/
readonly inProgress: readonly EnabledEntry[];
/** 待裁量评估的语义性前提 */
readonly judgements: readonly Redlight[];
/** 目标缺口:未绿的判据 */
readonly gaps: readonly string[];
/** 阶段位置:Active 的阶段 */
readonly activeStages: readonly string[];
};
/**
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
*/
export function publishEnabled(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
unmetCriteria: readonly string[] = [],
): EnabledSet {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
const entries: EnabledEntry[] = [];
const judgements: Redlight[] = [];
for (const item of model.items) {
const state = byId.get(item.id)?.state ?? 'Available';
if (state !== 'Available' && state !== 'Enabled') continue;
// 父阶段未激活的项不入场
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
let satisfied = true;
for (const criterion of item.entryCriteria) {
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
if (verdict.kind === 'needs-judgement') {
judgements.push({ criterion: criterion.id, statement: verdict.statement });
satisfied = false;
break;
}
if (verdict.kind === 'unsatisfied') {
satisfied = false;
break;
}
}
if (!satisfied) continue;
entries.push({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
});
}
return {
entries,
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
inProgress: model.items
.filter((item) => byId.get(item.id)?.state === 'Active')
.map((item) => ({
id: item.id,
name: item.name,
type: item.type,
requiresManualStart: item.manualActivation,
discretionary: item.discretionary,
})),
judgements,
gaps: unmetCriteria,
activeStages: runtime.items
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
.map((i) => i.id),
};
}
/**
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
* **交集为空即断点** —— 调人或立监听的判定时机。」
*/
export type Navigation =
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
| { readonly kind: 'converged' };
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
if (enabled.gaps.length === 0) return { kind: 'converged' };
const intersection =
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
/*
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
*/
if (intersection.length === 0) {
return enabled.inProgress.length > 0
? { kind: 'proceed', candidates: enabled.inProgress }
: { kind: 'breakpoint', gaps: enabled.gaps };
}
return { kind: 'proceed', candidates: intersection };
}
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
if (!model.autoComplete) return false;
const byId = new Map(runtime.items.map((i) => [i.id, i]));
/*
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
*/
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
const allDone = required.every((i) => {
const state = byId.get(i.id)?.state;
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
});
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
return allDone && noneRunning;
}
/**
* 终局唯放行与 Harness(必须):
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
* **AI 不得产生终局迁移**。
*/
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
export function mayCompleteCase(by: TerminalAuthority): boolean {
return by === 'release';
}
export function mayTerminateCase(by: TerminalAuthority): boolean {
return by === 'harness' || by === 'owner' || by === 'governance';
}
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
/**
* 前提达成即达成的里程碑 —— **不需要谁去按**。
*
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
* 让 Agent 每拍都看到一堆自己按不动的东西。
*
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
*/
export function achieveMilestones(
model: CaseModel,
runtime: CaseRuntime,
scope: Readonly<Record<string, unknown>>,
changedRefs: ReadonlySet<string>,
evaluate: ConditionEvaluator,
): { runtime: CaseRuntime; achieved: string[] } {
const byId = new Map(runtime.items.map((i) => [i.id, i]));
let next = runtime;
const achieved: string[] = [];
for (const item of model.items) {
if (item.type !== 'milestone') continue;
if (byId.get(item.id)?.state !== 'Available') continue;
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
if (item.entryCriteria.length === 0) continue;
const met = item.entryCriteria.every(
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
);
if (!met) continue;
next = occur(next, item.id);
achieved.push(item.id);
}
return { runtime: next, achieved };
}
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
};
}
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
const current = runtime.items.find((i) => i.id === itemId);
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
assertItemTransition(current.state, to);
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
}
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
}
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
return {
...runtime,
items: runtime.items.map((i) =>
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
),
};
}
/**
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
*
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
*
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
*/
export function criterionKey(raw: string): string {
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
/*
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
*
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
*
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
*/
return tight.replace(/\(l[1-5]\)$/, '');
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
*
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
*/
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
const names = new Set<string>();
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
}
if (names.size === 0) return '';
const readings = [...names].map((name) => {
const value = scope[name];
if (value === undefined || value === null) return `${name}=(没写过)`;
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
});
/*
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
*
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
*/
const rowKeys = [...names]
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
.map((n) => {
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
if (typeof listed !== 'string' || listed === '') return '';
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
})
.filter((s) => s !== '');
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
}