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:
xcs
2026-08-30 16:09:18 +08:00
co-authored by Claude Opus 5
parent 9f5b1c99d3
commit 45cec82984
19 changed files with 1078 additions and 69 deletions
@@ -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) {