Archived
fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口
**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。
**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。
**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。
**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。
**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。
debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。
包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
|
||||
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
|
||||
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
|
||||
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
|
||||
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
|
||||
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
|
||||
* `barred` 因此永远不会把它算进去;
|
||||
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
|
||||
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
|
||||
* 那条 2026-08-23 的保护对它不成立。
|
||||
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
|
||||
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
|
||||
* /说明**静默覆盖**到既有那一行头上。
|
||||
*
|
||||
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
|
||||
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
|
||||
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
|
||||
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
|
||||
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
|
||||
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
|
||||
* auto 列」的活动仍会踩上第一条用例这个坑。
|
||||
*/
|
||||
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
|
||||
const KEYS = ['type_id'];
|
||||
const TYPES = { type_id: 'text' };
|
||||
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
|
||||
const ONE_EXISTING = [{ key: { type_id: '1' } }];
|
||||
|
||||
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
|
||||
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
|
||||
expect(out).toEqual({ type_id: '1' });
|
||||
});
|
||||
|
||||
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
expect(out).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
|
||||
@@ -781,4 +781,44 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **`recipe.let` 同一条审读**(2026-08-30 全分支终审 Important 1)——`let` 与 `fields`
|
||||
* 的依赖读法不一样(`let` 的官方跨条目读法是 `{in:X}`,不是 `when` 里的裸标识符),
|
||||
* 所以静态审那边是单独一份 `letsCycle`/`letReferences`,这里单独一组测试钉住它,
|
||||
* 不是「已经被 fields 那组测过了」。
|
||||
*/
|
||||
it('recipe.let 两条互相依赖({in:X} 互指)→ error,点名两个 let', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { a: { in: 'b' }, b: { in: 'a' } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||
}),
|
||||
);
|
||||
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.message).toContain('a');
|
||||
expect(hit?.message).toContain('b');
|
||||
expect(hit?.message).toContain('let');
|
||||
});
|
||||
|
||||
it('recipe.let 链式依赖(不是环,只是 tag 用 {in:"stamp"} 读 stamp)→ 不报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { stamp: 'W05-', tag: { cat: [{ in: 'stamp' }, 'X'] } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: { in: 'tag' } } }],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
|
||||
it('recipe.let 引用起单入参 / case: 这类全局名字不算依赖,不误报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { a: { in: 'someInput' }, b: { in: 'case:openedAt' } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,6 +431,50 @@ function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly strin
|
||||
return result.ok ? undefined : result.cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同上,但专为 `recipe.let` 用(2026-08-30 全分支终审 Important 1)——多认一种依赖:
|
||||
* **裸 `{in:X}` 本身就是依赖**。`let` 的官方跨条目读法就是 `{in:X}`(见
|
||||
* `packages/contracts/src/auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能
|
||||
* {in:'名'} 引用」,运行时 `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——
|
||||
* 与 `fields` 相反(`fieldReferences` 故意不认 `{in:X}`:那边的 `{in:X}` 只读
|
||||
* `lets`/`scope`,永远不会命中同一个 `fields` 对象里的另一个字段,认了反而误报)。
|
||||
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `fieldReferences` 完全一致。
|
||||
*
|
||||
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `letReferences` 是同一个算法,
|
||||
* 写了两遍**——理由同 `fieldReferences` 头注(L4 装包静态审不能 import L5 配方执行器)。
|
||||
* 改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function letReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('in' in expr) return [expr.in];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** `recipe.let` 内部求值依赖能不能排出安全序(与运行时 `orderLets` 同一件事)。装的时候就该看出来,不留到运行时才炸。 */
|
||||
function letsCycle(lets: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||
const entries = Object.entries(lets);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
return result.ok ? undefined : result.cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
|
||||
*
|
||||
@@ -546,6 +590,23 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
|
||||
/* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */
|
||||
const letNames = new Set(Object.keys(recipe.let ?? {}));
|
||||
for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`);
|
||||
/*
|
||||
* **`let` 之间的求值依赖不能成环**(2026-08-30 全分支终审 Important 1,与下面 `fields`
|
||||
* 那条 `fieldsCycle` 同一课):`let` 存进 jsonb 之后键序不再保证,运行时已经按依赖
|
||||
* (`{in:X}`)排出安全序求值(见 `orderLets` 头注),但 A 依赖 B、B 又依赖 A 排不出
|
||||
* 任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||
*/
|
||||
{
|
||||
const cycle = letsCycle(recipe.let ?? {});
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `let 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个 let 是否互相用 {in:...} 或 when 引用了对方)`,
|
||||
at: `${at}.let`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, action] of recipe.do.entries()) {
|
||||
const where = `${at}.do[${index}]`;
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoExpr } from '@contracts';
|
||||
import { AutoFieldCycleError, orderFields } from '../internal/domain/auto-order.ts';
|
||||
import { AutoFieldCycleError, orderFields, orderLets } from '../internal/domain/auto-order.ts';
|
||||
|
||||
/** 只看排出来的字段名序列,不看表达式本身 */
|
||||
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
|
||||
/** 同上,给 `orderLets` 用 */
|
||||
const letNames = (lets: Readonly<Record<string, AutoExpr>>) => orderLets(lets).map(([name]) => name);
|
||||
|
||||
describe('orderFields', () => {
|
||||
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
|
||||
@@ -119,3 +121,101 @@ describe('orderFields', () => {
|
||||
expect(out).toEqual([['only', expr]]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `orderLets` —— `recipe.let` 版的求值序(2026-08-30 全分支终审 Important 1)。
|
||||
*
|
||||
* `let` 与 `fields` 的依赖读法**不一样**:`fields` 的字段互相看见只能靠 `when` 字符串
|
||||
* 里的裸标识符(`{in:X}` 在 `fields` 里读 `lets`/`scope`,读不到同一个 `fields` 对象里
|
||||
* 的另一个字段);`let` 的官方跨条目读法**就是** `{in:X}`(`packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」)。所以这里
|
||||
* 单独一组测试,不是照抄 `orderFields` 那组把函数名换一下 —— 头两条专门钉住这个差异。
|
||||
*/
|
||||
describe('orderLets', () => {
|
||||
it('**`{in:X}` 本身就是依赖**(let 与 fields 的关键差异):B 用 {in:"a"} 读 A,A 必须排到 B 前面', () => {
|
||||
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
b: { cat: [{ in: 'a' }, '-suffix'] },
|
||||
a: 'x',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
});
|
||||
|
||||
it('三级链,全靠 {in:X} 串起来:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
|
||||
const chain: Record<string, AutoExpr> = {
|
||||
evalRef: { in: 'case:openedAt' },
|
||||
/* B:{in:X} 提到 A */
|
||||
stamp: { cat: ['W05-', { in: 'evalRef' }] },
|
||||
/* C:{in:X} 提到 B */
|
||||
tag: { cat: [{ in: 'stamp' }, '-X'] },
|
||||
};
|
||||
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去
|
||||
const jsonbLike: Record<string, AutoExpr> = {};
|
||||
for (const key of ['tag', 'stamp', 'evalRef']) jsonbLike[key] = chain[key] as AutoExpr;
|
||||
|
||||
const order = letNames(jsonbLike);
|
||||
expect(order.indexOf('evalRef')).toBeLessThan(order.indexOf('stamp'));
|
||||
expect(order.indexOf('stamp')).toBeLessThan(order.indexOf('tag'));
|
||||
});
|
||||
|
||||
it('when 字符串里的裸标识符同样算依赖(与 fields 那半条规矩一致,let 的 when 也进含 ...lets 的合并作用域)', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
|
||||
a: 'x',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
});
|
||||
|
||||
it('依赖藏在 lookup.by / else / cat / fmt 里也认得出(与 fields 那组同款覆盖)', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
target: { lookup: 'parts.chip_type', by: { type_id: { in: 'source' } }, take: 'type_name', else: '' },
|
||||
source: { fmt: { in: 'raw' }, as: 'digits' },
|
||||
raw: '5',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
|
||||
expect(order.indexOf('raw')).toBeLessThan(order.indexOf('source'));
|
||||
});
|
||||
|
||||
it('没有互相依赖的 let:谁先谁后都行,数量对得上就好', () => {
|
||||
const lets: Record<string, AutoExpr> = { b: { in: 'x' }, a: 'literal', c: { in: 'y' } };
|
||||
expect(letNames(lets).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('只认同一个 let 对象内的名字 —— {in:X} 引用起单入参 / case: / 基座读数这类全局名字不算依赖,不参与排序', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
a: { in: 'originalInternalPn' },
|
||||
b: { in: 'case:openedAt' },
|
||||
};
|
||||
// 两条互不相干,不该抛错,数量对得上即可
|
||||
expect(letNames(lets).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('真正的环({in:X} 互指):抛 AutoFieldCycleError,话里点名是 let(noun 参数),不默默给错答案', () => {
|
||||
const lets: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
|
||||
expect(() => orderLets(lets)).toThrow(AutoFieldCycleError);
|
||||
try {
|
||||
orderLets(lets);
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AutoFieldCycleError);
|
||||
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
|
||||
expect((err as Error).message).toContain('循环依赖');
|
||||
expect((err as Error).message).toContain('let');
|
||||
}
|
||||
});
|
||||
|
||||
it('自己依赖自己({in:X} 提到自己的名字)不算环 —— 常见写法是把同名入参"提升"进 let 命名空间', () => {
|
||||
const lets: Record<string, AutoExpr> = { chip_type_id: { in: 'chip_type_id' } };
|
||||
expect(() => orderLets(lets)).not.toThrow();
|
||||
expect(letNames(lets)).toEqual(['chip_type_id']);
|
||||
});
|
||||
|
||||
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
|
||||
const expr: AutoExpr = { in: 'evalRef' };
|
||||
const out = orderLets({ only: expr });
|
||||
expect(out).toEqual([['only', expr]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -978,3 +978,107 @@ describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **回归:jsonb 打乱 `recipe.let` 键序,同样必须求出与原声明序一致的结果**
|
||||
* (2026-08-30 全分支终审 Important 1)。
|
||||
*
|
||||
* H47(上面那一大段)只治了 `fields` 那一半 —— `recipe.let` 同样住在
|
||||
* `sys.activity_package.manifest` 这个 jsonb 列,同样会被 PG 按「先短后长、同长字典序」
|
||||
* 重排。而 `let` 的官方跨条目读法**就是** `{in:X}`(见 `packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」),后一条 let
|
||||
* 靠它读前一条已经算出的值 —— 键序被打乱,`{in:X}` 会读到 `undefined`,与 `fields` 那次
|
||||
* 事故是**同一种病灶、同一种沉默失败**(不报错、结果看着完整、只是算错)。
|
||||
*
|
||||
* 下面 `LETS` 故意让**被依赖的那一条名字更长**(`originalStamp`,13 字)、**依赖它的那一条
|
||||
* 名字更短**(`tag`,3 字)——jsonb「先短后长」重排会把 `tag` 排到 `originalStamp` **前面**,
|
||||
* 恰好是「后一条 let 引用前一条,键序被 jsonb 重排会颠倒」这个具体形状。
|
||||
*/
|
||||
describe('回归 · jsonb 打乱 recipe.let 键序,求值仍要与原声明序一致(2026-08-30)', () => {
|
||||
/** 与上面 `reorderLikeJsonb` 同一个规则,这个 describe 块单独起一份(两处各自独立,互不依赖对方的实现细节) */
|
||||
function reorderLikeJsonb<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
|
||||
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
|
||||
const out: Record<string, T> = {};
|
||||
for (const key of keys) out[key] = fields[key] as T;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */
|
||||
const LETS: Record<string, AutoExpr> = {
|
||||
originalStamp: { in: 'caseSeed' },
|
||||
tag: { cat: [{ in: 'originalStamp' }, '-X'] },
|
||||
};
|
||||
|
||||
it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => {
|
||||
expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']);
|
||||
});
|
||||
|
||||
function harness(seed: Record<string, unknown>) {
|
||||
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve({ ...seed }),
|
||||
writable: () =>
|
||||
Promise.resolve([{ domain: 'demo', object: 'record', label: '演示记录', keys: ['case_ref'], fields: [] }]),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
drafts.set(id, { key: i.key, patch: i.patch });
|
||||
return Promise.resolve({ changed: true });
|
||||
},
|
||||
draftDrop: () => Promise.resolve({ dropped: false }),
|
||||
},
|
||||
cards: {
|
||||
issue: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
decide: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
entry: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
ask: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: { append: () => Promise.resolve() },
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||
sfeel: {
|
||||
condition: (expr: string, scope: Record<string, unknown>) => evaluateCondition(expr, scope),
|
||||
expression: () => undefined,
|
||||
unaryTest: () => false,
|
||||
},
|
||||
};
|
||||
const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||
return { runner: createAutoRunner(deps as never), drafts, ctx };
|
||||
}
|
||||
|
||||
const recipeOf = (lets: Record<string, AutoExpr>): AutoRecipe => ({
|
||||
let: lets,
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'CR-1', computed_tag: { in: 'tag' } } }],
|
||||
});
|
||||
|
||||
it('声明序与打乱成 jsonb 重排序,求出的 let 值必须完全相同、且是对的(不是空字符串兜底)', async () => {
|
||||
const seed = { caseSeed: 'W05-20260830' };
|
||||
|
||||
const declared = harness(seed);
|
||||
const declaredOut = await declared.runner.run(recipeOf(LETS), declared.ctx);
|
||||
|
||||
const shuffled = harness(seed);
|
||||
const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(LETS)), shuffled.ctx);
|
||||
|
||||
expect(declaredOut.failedAt).toBeUndefined();
|
||||
expect(shuffledOut.failedAt).toBeUndefined();
|
||||
|
||||
const declaredTag = [...declared.drafts.values()][0]?.patch['computed_tag'];
|
||||
const shuffledTag = [...shuffled.drafts.values()][0]?.patch['computed_tag'];
|
||||
|
||||
/* 正确答案:originalStamp = 'W05-20260830'(读 scope),tag = 'W05-20260830' + '-X' —— 打乱前不改变这一点 */
|
||||
expect(declaredTag).toBe('W05-20260830-X');
|
||||
/* 求值序被治好之前,这里会是 '-X'(打乱后 `originalStamp` 还没算,`{in:'originalStamp'}` 读到 undefined→'') */
|
||||
expect(shuffledTag).toBe('W05-20260830-X');
|
||||
expect(shuffledTag).toBe(declaredTag);
|
||||
});
|
||||
|
||||
it('真正的环(a 依赖 b、b 依赖 a)—— 不抛出去炸掉整个回合,交回 failedAt 且话里点名是 let', async () => {
|
||||
const cyclic: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
|
||||
const h = harness({});
|
||||
const out = await h.runner.run(recipeOf(cyclic), h.ctx);
|
||||
expect(out.failedAt).toBeDefined();
|
||||
expect(out.failedAt?.message).toContain('循环依赖');
|
||||
expect(out.failedAt?.message).toContain('let');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,13 @@ function harness(
|
||||
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
|
||||
issueRejects?: string;
|
||||
/**
|
||||
* **只让 `workOrder:'acceptance'` 那一句抛**(2026-08-30 终审 I4)——模拟落地预检
|
||||
* 拒发验收卡(`raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
|
||||
* 抛出的那种);与 `issueRejects`(不分 workOrder 一律抛)分开,好让「验收卡发不出去、
|
||||
* 但兜底的执行工单发得出去」这个场景测得出来。
|
||||
*/
|
||||
acceptanceIssueRejects?: string;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -186,6 +193,9 @@ function harness(
|
||||
cards: {
|
||||
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
|
||||
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
|
||||
if (over.acceptanceIssueRejects !== undefined && input.workOrder === 'acceptance') {
|
||||
throw new Error(over.acceptanceIssueRejects);
|
||||
}
|
||||
issued.push({ workOrder: input.workOrder, card: input.card });
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
@@ -2429,6 +2439,68 @@ describe('auto 步:配方在拍首跑', () => {
|
||||
expect(lines[0]?.who).toBe('system');
|
||||
});
|
||||
|
||||
/*
|
||||
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」
|
||||
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
|
||||
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
|
||||
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
|
||||
* `custom:` 走**对话卡族**,不是 `approve`)。`card-rules.ts` 的 `checkChatCardReply`
|
||||
* 认这个答复合法(`allowCustom` 对审核卡默认真,见 `engines.ts:1507`
|
||||
* `allowCustom: input.allowCustom ?? reviewFace`)、`worldTouched` 照样落 true ——
|
||||
* 于是这条「卡答过了就收步」的判断(:2179,四个前提:`noDoneWhen && carded &&
|
||||
* recipeIssuesCard(recipe) && current.guide.trim() === ''`)一个字都不看
|
||||
* `reply.value`/`chosen.value` 是 `approve` 还是 `custom:...`,一样收步、一样往前推。
|
||||
*
|
||||
* **为什么不能靠活动侧加一个 `reject` 选项治**:上面那四个前提里没有一条查了选项或
|
||||
* 选中的值,给卡加选项不会改变这个判断的走向——同一张卡不管选中哪一个,只要
|
||||
* `worldTouched` 变真,这一步就被收。
|
||||
*
|
||||
* **为什么 2026-08-27 那次「chosenGuidance 分岔」治不了这一条**:那次治的是**半 auto**
|
||||
* 路径(`guide` 非空、真的会去问模型的那条)—— `answeredCardFact`/`chosenGuidance`
|
||||
* 拼进的是**喂给模型的提示词**(:2255 `staged` 数组),而这里根本不问模型
|
||||
* (`current.guide.trim() === ''` 正是触发这条近道的四个前提之一)。**没有模型可问,
|
||||
* 就没有提示词可以说服** —— 同一症状("审核那一步被收掉、一路推到放行",
|
||||
* `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上
|
||||
* 重演,而那次的修法结构性地够不到这条路径。
|
||||
*
|
||||
* **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清
|
||||
* 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的
|
||||
* `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡**
|
||||
* (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、
|
||||
* 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同,
|
||||
* 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、
|
||||
* 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给
|
||||
* 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。
|
||||
*/
|
||||
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
// 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到**
|
||||
trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }],
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
/*
|
||||
* **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算"
|
||||
* (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果
|
||||
* 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。
|
||||
*/
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7
|
||||
* 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回),
|
||||
@@ -2636,6 +2708,79 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('一个字没改');
|
||||
});
|
||||
|
||||
/*
|
||||
* **Important 4(2026-08-30 全分支终审)**:上面四条测的都是「打回重做」那条路
|
||||
* (`trail` 里有 `REDO_MARK`)。这三条测**从没被打回过、纯粹是验收判据(L1/L2 机检)
|
||||
* 没过**的那条路——这一步没有 guide(全 auto),没有模型会读「提交放行被拒」这句话,
|
||||
* 台账在更早一步就已经收口、没有活的编辑面能让人就地改:下一拍重跑只会拿到同一份
|
||||
* 数据、撞回同一堵墙,纯粹烧步数预算直到 exhausted(task-service 的 markProgress
|
||||
* 每拍都记,stallAfterMs 那道熔断因此不会提前触发)。用 `acceptanceIssueRejects`
|
||||
* 模拟 `raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
|
||||
* 被落地预检拒发(这正是 W05 一颗 UNKNOWN 差异行漏填去向时,`every-param-judged`[L2]
|
||||
* 会真实抛出的那种错误)。
|
||||
*/
|
||||
it('Important 4 · 头一次撞验收判据的墙(不是打回重做):留痕、状态仍 running —— 给它一次机会(软化那半段)', async () => {
|
||||
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
acceptanceIssueRejects: reason,
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('running');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('每条核心参数都有明确状态');
|
||||
});
|
||||
|
||||
it('Important 4 · 同一堵墙第二次(trail 里已有同一句拒回痕)→ 不再空转到 exhausted,发兜底执行工单交人、立监听', async () => {
|
||||
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
acceptanceIssueRejects: reason,
|
||||
// 模拟"上一拍已经在这堵墙上留过一条一模一样的痕"
|
||||
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
/* 发的是兜底的**执行工单**,不是 workOrder:'acceptance'(那正是刚撞过的那堵墙,再发一遍只会再撞一次) */
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(h.issued[0]?.workOrder).toBe('execution');
|
||||
expect(String(h.issued[0]?.card['title'])).toContain('T_release');
|
||||
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
|
||||
expect(basis.find((b) => b.k === '这一步')?.v).toBe('T_release');
|
||||
expect(basis.find((b) => b.k === '拒回原话')?.v).toContain('每条核心参数都有明确状态');
|
||||
// 立了人事件监听,人一答就叫得醒(不是伪装成"等外部回音"却没人会来)
|
||||
expect(h.listened).toEqual([{ eventType: 'human' }]);
|
||||
});
|
||||
|
||||
it('Important 4 · 兜底执行工单自己也发不出去(如调人预算触顶):不抛、状态仍 awaiting、留痕两层原因', async () => {
|
||||
const reason = '调人预算已经触顶(3/3)';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
// issueRejects 对任何 workOrder 都抛 —— 验收卡(raiseAcceptance 内部)与
|
||||
// 兜底执行工单(下面新增的那一支)两次尝试都会撞上同一句话
|
||||
issueRejects: reason,
|
||||
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
|
||||
expect(note).toBeDefined();
|
||||
expect(note?.title).toContain('调人预算已经触顶(3/3)');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 配方字段求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* `AutoAction.fields` / `analyze.input.fields` 存进 `sys.activity_package.manifest`
|
||||
* (`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:PostgreSQL 的 `jsonb` 按
|
||||
* 「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
|
||||
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
|
||||
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||
*
|
||||
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
|
||||
@@ -15,14 +15,35 @@
|
||||
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
|
||||
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
|
||||
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
|
||||
*
|
||||
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
|
||||
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
|
||||
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
|
||||
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
|
||||
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
|
||||
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
|
||||
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
|
||||
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
|
||||
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
|
||||
* 不是简单复用 `whenReferences`/`orderFields`。
|
||||
*/
|
||||
import type { AutoExpr } from '@contracts';
|
||||
import { staticCheck } from '@sfeel';
|
||||
import { topoOrder } from '@utils';
|
||||
|
||||
/**
|
||||
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
|
||||
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
|
||||
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
|
||||
*/
|
||||
export class AutoFieldCycleError extends Error {
|
||||
constructor(readonly cycle: readonly string[]) {
|
||||
super(`字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`);
|
||||
constructor(
|
||||
readonly cycle: readonly string[],
|
||||
noun: '字段' | 'let' = '字段',
|
||||
) {
|
||||
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
|
||||
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
|
||||
this.name = 'AutoFieldCycleError';
|
||||
}
|
||||
}
|
||||
@@ -80,3 +101,53 @@ export function orderFields(fields: Readonly<Record<string, AutoExpr>>): Readonl
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
|
||||
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
|
||||
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
|
||||
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
|
||||
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
|
||||
*
|
||||
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
|
||||
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
|
||||
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function letReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('in' in expr) return [expr.in];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
|
||||
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
|
||||
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
|
||||
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
|
||||
*/
|
||||
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||
const entries = Object.entries(lets);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const byName = new Map(entries);
|
||||
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
||||
*/
|
||||
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
|
||||
import { AutoFieldCycleError, orderFields } from '../domain/auto-order.ts';
|
||||
import { AutoFieldCycleError, orderFields, orderLets } from '../domain/auto-order.ts';
|
||||
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
|
||||
import { expandSource } from '../domain/auto-source.ts';
|
||||
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
|
||||
@@ -107,9 +107,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
|
||||
const condition = deps.sfeel.condition;
|
||||
const lets: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
/*
|
||||
* **`let` 算好之前不许任何一笔动作跑**(2026-08-30 终审 Important 1)—— 计算挪到了
|
||||
* 下面主循环第一次迭代的 try 里(见那一段注释),`letsReady` 只是标一下算过没有。
|
||||
*/
|
||||
let letsReady = false;
|
||||
|
||||
async function runAction(
|
||||
action: AutoAction,
|
||||
@@ -361,6 +363,25 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
dirty = false;
|
||||
}
|
||||
/*
|
||||
* **`let` 与 `fields` 同一个 jsonb 重排隐患,同一套治法**(2026-08-30 终审
|
||||
* Important 1,H47 当时只治了 `fields` 那一半 —— 见 `auto-order.ts` 头注)。
|
||||
* `{in:X}` 是 `let` 唯一的官方写法,后一条 let 靠它读前一条已经算出的值,
|
||||
* 键序被 jsonb 打乱同样会让这条依赖读到 `undefined`(未绑定的名字,不报错、
|
||||
* 静默算错)。改按 `orderLets` 的依赖拓扑序求值,只算一次(`letsReady`)。
|
||||
*
|
||||
* **放进第一次循环的 try 里,不是单独一段**——唯一目的是**复用同一个 catch**:
|
||||
* 真撞上环(该在装包静态审就被拦下,见 `static-audit.ts` 新增的 `letsCycle`
|
||||
* 检查,这里是最后一道兜底)交人的路子,与某一笔配方动作本身办不下去完全一样,
|
||||
* 不必另开一条"let 专属"的失败通道(`failedAt.index` 报的是 0,即"第 1 笔"——
|
||||
* 不完全精确,但 `failed.message` 里的原话已经说清是 let 算不出来,够用)。
|
||||
*/
|
||||
if (!letsReady) {
|
||||
for (const [name, expr] of orderLets(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
letsReady = true;
|
||||
}
|
||||
const base: ExprCtx = { scope, lets, condition };
|
||||
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
||||
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
|
||||
|
||||
@@ -159,6 +159,13 @@ const REDO_MARK = '验收被打回重做';
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
||||
const AUTO_FAIL_MARK = '配方办不下去';
|
||||
/**
|
||||
* 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 ——
|
||||
* 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支,
|
||||
* 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面
|
||||
* `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。
|
||||
*/
|
||||
const AUTO_SUBMIT_REFUSE_MARK = '提交放行被拒';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
@@ -2133,13 +2140,78 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
/*
|
||||
* **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。
|
||||
*
|
||||
* 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步
|
||||
* (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`,
|
||||
* `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个
|
||||
* `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因
|
||||
* (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈:
|
||||
* 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份
|
||||
* 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算,
|
||||
* 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记,
|
||||
* `stallAfterMs` 那道熔断因此永远不会提前触发)。
|
||||
*
|
||||
* **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的
|
||||
* 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次
|
||||
* 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对,
|
||||
* 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙:
|
||||
* 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式——
|
||||
* 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。
|
||||
*/
|
||||
const already = (await deps.memory.window(taskId, STALL_WINDOW)).some(
|
||||
(t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `提交放行被拒:${reason}`,
|
||||
title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
|
||||
});
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
if (!already) {
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
}
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
try {
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' +
|
||||
'也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' +
|
||||
'数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' +
|
||||
'判据或配方本身有问题就找开发改完重跑。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '拒回原话', v: reason },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
} catch (issueError) {
|
||||
const issueReason = issueError instanceof Error ? issueError.message : String(issueError);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`,
|
||||
detail: {
|
||||
auto: current.item,
|
||||
hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (auto.issuedCardId !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user