fix(auto): 配方字段按依赖拓扑求值 —— jsonb 会重排对象键,声明顺序靠不住

真跑 e2e 逼出来的地基缺陷。活动清单存在 sys.activity_package.manifest 这个
**jsonb 列**里,而 PG 按「先短后长、同长字典序」重排所有嵌套对象的键。
auto-runner 原先逐字段**按声明顺序**求值、后面的字段靠 ctx.computed 看前面的值 ——
顺序一乱,依赖就断,而且**一声不响**。

W05 实证:judgement_class(15B)被排到它依赖的 difference_status(17B)前面,
求值时后者恒 undefined、when 三级全落空,摔进 constraint_type 兜底档 ——
7 行差异结论全判错,与实际状态无关;而 difference_status 走
lookup:'result:items' 不依赖字段序,所以它是对的。「一个对一个全错」由此而来。
隔离测试复现不出来,因为它直接读 dist/manifest.json,从不经 DB 往返。

修法:字段求值改为**依赖拓扑排序**(Kahn),不用迭代到不动点 ——
后者的数值收敛判据会漏掉「A/B 互相 when 为假、恰好在错误状态下稳定」这种
结构有环却数值收敛的配方;拓扑是结构性判定,不会假阴性。
通用图算法 topoOrder 提进 @utils(契约新增,非破坏);
AutoExpr→依赖名提取因 L4 不能 import L5,在 loop-engine 与 activity-registry
各留一份并互相指路。真正的环由装包静态审当场拒装 —— 不兜底,兜底正是这次出事的形态。

变异验证:git stash 只撤 auto-runner.ts 那一处,两条新测试当场变红,
数值与专查报告的 SQL 转录逐字吻合(使能阈值/match 被误判 hard_mismatch/medium);
stash pop 后 37/37 绿。E04 92/92、I01 55/55 全绿;全仓 2447 测通过。

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 07:05:48 +08:00
co-authored by Claude Opus 5
parent 67fa226380
commit 6636abfc39
10 changed files with 840 additions and 6 deletions
+43
View File
@@ -1499,3 +1499,46 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
号从 `1` 起,最容易撞上种子数据里最早的几行)。
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见)
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
`project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果
已经因为「数据工单确认即入库」(`landsOnConfirm`,2026-08-27)落进了正式表,`draftDrop`
在 `apps/api/src/app/wiring/engines.ts:1217-1231` 那道显式检查前会被拒
(`「…已入库的,撤不掉;要改由人在件上改」`),配方抛 `AutoRecipeError`,这一步再也办不下去,
单子吊死在 `awaiting`,只能靠人工干预。
复现路径(W05 `part-substitution-assessment@2.1.5`,案例「两颗都在库但分属不同芯片类型」):
①人对着两颗类型不一致但各自字段都齐的料,第一次提交(哪怕什么都没改,提交闸也放行,
因为两颗料本就不缺格子)——这一步**已经落地**,把其中一颗按它*当时*的类型字段集
(如 7 项)写进了正式表;②人接着把它转挂到*另一个*类型(字段集不同,如 5 项)、补上新类型
要求的字段;③配方按新类型重新铺 `parts.parameter` 骨架,`prune:true` 想把①落地的那 7 项里、
不在新的 5 项字段集中的几项(如 3 项)撤掉——它们已经落地,③在这里被 `engines.ts:1217`
拒,`AutoRecipeError`。
**这是真人会走的路径,不是测试造出来的**:人确认一张数据工单之后才发现类型挑错了、
需要改类型,是完全正常的操作顺序。
**两条机制各自都对**:`prune` 是活动清理陈旧行的正当手段(design spec §4.1 就是这么设计的:
「计算出的新集合就是这张表的全部,多出来的撤掉」);「已落地行不许 `draftDrop`」是防止
撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**:
`prune` 想撤、平台不让撤。
与 2026-08-30 已记的另一条(**范围放行没有对 `refersTo` 做传递闭包**,见「ACT-WORK-05 走查
逼出来的三条平台欠账」附近的记录)同族——两条都是「数据工单确认即入库」这个 2026-08-27
新加的机制与**别的**既有机制(`prune`、`refersTo` 的传递闭包)之间没有对齐接缝造成的。
**要治**(方向,未验证,留给真正动手时评估):
- 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于
`markLanded` 的反操作(清 `landed_at`),把它重新变回「本单还没最终确定」的草稿状态,
再走正常的 `draftDrop`;risk:这一改会让正式表暂时保留一条其实已经不该存在的行,
直到下一次真正放行(`releaseWrite`)才会被撤下——需要确认这段「暂时不一致」窗口对下游
(读表的其它任务、报表)是否可接受。
- 或者 `prune` 对已落地行降级成「跳过、留着但记一条观察」,不当错误——把「这几行已经落地、
这一笔没法帮你清」的实情说给人听,交给他在**放行那一步**的判据/清单里手动确认要不要紧,
而不是让整单卡死。
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
某张数据工单的 `gateLedgers` 上时,概率最高。