diff --git a/CLAUDE.md b/CLAUDE.md index faa5005..4b0811c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1669,7 +1669,12 @@ **e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 / 都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口 - `assessment-released` 全对;类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁 + `assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻** + (见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**, + 没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是 + 「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目), + 这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。 + 类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁 (`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择, `prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例 卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说 @@ -1704,7 +1709,7 @@ | | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 | | | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) | | | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | -| | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) | +| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` | | | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 | | | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) | | | `docs/debt.md` | 欠账台账(只增,销账划线) | diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index bc33b67..b5f04e0 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -127,9 +127,14 @@ describe('清单本身合规', () => { * 2026-08-29 重写:启动表单从「需求 + 原PN + 候选数组 + datasheet」四件收到「原PN + 候选」两件 * (单数,一次评估恰一对一候选)—— `requestDescription` / `candidateInternalPns`(数组)/ * `datasheets` 三键随 P-01 需求主记录一起撤下(本活动不再碰它)。逐条断言见「契约 > 启动两参」。 + * + * **2026-08-30 终审 Critical 2 改判**:两格从「都选填」改成**都必填**——旧断言 + * `toEqual([])` 钉的正是那条留空即永久卡死在 S1 的死路(gate_reason 指向一条不存在的 + * 「加一行」路径),详见「契约 > 启动两参」那条测试与 `manifest.ts`/`case-model.ts` + * 里的大注释。「客户与需求两类字段已随重写撤下」这半句结论不变。 */ - it('起单一格必填都不设(2026-08-28 用户定);客户与需求两类字段已随重写从启动表单撤下', () => { - expect(manifest.contract.input.data['required']).toEqual([]); + it('起单两格都必填(2026-08-30 修 Critical 2 改判);客户与需求两类字段已随重写从启动表单撤下', () => { + expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']); const launchProps = Object.keys(manifest.contract.input.data['properties'] as Record); expect(launchProps).not.toContain('customerCode'); expect(launchProps).not.toContain('customerName'); @@ -262,11 +267,20 @@ describe('台账声明', () => { }); describe('契约', () => { - it('启动两参,都选填 —— 缺了由第一步派工单补', () => { + /* + * **2026-08-30 终审 Critical 2**:两颗料号是这件事的身份,不是可以事后补的细节 —— + * 都选填时留空,`sub_eval.part` 的两行会因为同一个空主键 `internal_pn=''` 撞成一行, + * `gate_reason` 判「还没填全」而块① 没有「加一行」的路,doneWhen 永不成立,整单 + * 永久停在 S1(链条见 `manifest.ts`/`case-model.ts` 的大注释)。改必填不是把人堵死—— + * 平台既有的「受理归一」机制(`task-service.ts` 的 `missingRequired`/`askCompletion`) + * 接住它:起单缺这两格,任务落成 `awaiting-human`,一张补全卡问负责人要,答完才开跑, + * 不是门口拒单。主数据(品牌/类型/参数)仍然缺了才由第一步派数据工单补。 + */ + it('启动两参,都必填 —— 两颗料号是身份不是细节,留空会让整单永久卡死在 S1', () => { const props = manifest.contract.input.data['properties'] as Record>; expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']); - expect(manifest.contract.input.data['required']).toEqual([]); - /* 运营端启动表单:两格都是「可选可新写」的下拉 */ + expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']); + /* 运营端启动表单:两格仍是「可选可新写」的下拉 —— 必填只挡空值,不挡「库里没有就自己写一个新的」 */ for (const key of ['originalInternalPn', 'candidateInternalPn']) { expect(props[key]?.['x-control']).toBe('combo'); expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' }); @@ -574,15 +588,29 @@ describe('件① 评估输入补全', () => { }); /* - * `auto:true` 的主键(`type_id`)**不该出现在种子行里**——自增列不问人(与联系人编码 - * `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个手写输入格。 + * `auto:true` 的主键(`type_id`)**人不填它**——自增列不问人(与联系人编码 + * `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个可填输入格, + * `type_id` 自己那一格**必须**是一个真的、禁用的 `data-col` 输入格(值恒空), + * **不能干脆不给** `data-col`。 + * + * **2026-08-30 终审 Critical 1 的回归钉**(反着来的教训:这条测试原来断言 + * `not.toContain('data-col="type_id"')`,而正是那个「不给 data-col」的形状让 + * `desk.ts` 的 `resolveRowKey` 认不出「这一列人碰过没有」,退回「全表一致」把 + * 新建的类型误判成种子字典里唯一那一行、静默改名覆盖它——详见 `views/report.ts` + * `CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts` + * 里对真实 `resolveRowKey` 的因果验证)。这条测试当年"证明"的是病灶本身,现在改成 + * 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」 + * 这句占位,但怎么点都改不了它的值。 */ - it('**新建类型的种子行不问 type_id**(自增列不问人):已有行的 type_id 只读、没有孪生输入框', () => { - expect(html).toContain('系统生成'); - // 「系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行,断言里面没有 data-col="type_id" - const chipTypeStaticRow = /(?:(?!<\/tr>)[\s\S])*?系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? ''; + it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => { + expect(html).toContain('由系统生成'); + // 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行 + const chipTypeStaticRow = /(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? ''; expect(chipTypeStaticRow).not.toBe(''); - expect(chipTypeStaticRow).not.toContain('data-col="type_id"'); + /* 必须是一个真的 (collect() 只认 input/select/textarea[data-col]), + 不能是纯文本 —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */ + expect(chipTypeStaticRow).toMatch(/]*data-col="type_id"[^>]*disabled/); + /* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */ expect(chipTypeStaticRow).toContain('data-col="type_name"'); expect(chipTypeStaticRow).toContain('data-col="description"'); }); diff --git a/activities/part-substitution-assessment/__tests__/steps.test.ts b/activities/part-substitution-assessment/__tests__/steps.test.ts index 135f3ae..1d7b6e6 100644 --- a/activities/part-substitution-assessment/__tests__/steps.test.ts +++ b/activities/part-substitution-assessment/__tests__/steps.test.ts @@ -194,6 +194,23 @@ describe('S1 配方', () => { expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' }); }); + /* + * **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的 + * `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 —— + * 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案 + * 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有 + * 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。 + */ + it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => { + const kase = acts[5]; + const fields = kase?.kind === 'project' ? kase.fields : undefined; + const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined; + expect(gateReason?.when).toBe('sub_eval_part_rows < 2'); + expect(gateReason?.then).not.toContain('还没填全'); + expect(gateReason?.then).not.toContain('把两格补上'); + expect(gateReason?.then).toContain('终止'); + }); + it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => { const params = acts[4]; expect(params?.kind === 'project' && params.prune).toBe(true); diff --git a/activities/part-substitution-assessment/src/case-model.ts b/activities/part-substitution-assessment/src/case-model.ts index 513cc08..64fffe9 100644 --- a/activities/part-substitution-assessment/src/case-model.ts +++ b/activities/part-substitution-assessment/src/case-model.ts @@ -335,7 +335,12 @@ export const ASSESSMENT_STEPS = [ eval_ref: { in: 'evalRef' }, internal_pn: { col: 'pn' }, role: { col: 'role' }, - /* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */ + /* + * 先取依据,再按依据判 —— `exists_status` 的 `when` 引用 `found_pn_ref`, + * `orderFields` 按这条依赖排出安全求值序(不是「声明顺序恰好写对了」, + * 2026-08-30 治 jsonb 键序重排那次改的:见 `auto-order.ts` 头注), + * 算出来的进同一行的 S-FEEL 作用域。 + */ found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' }, exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' }, chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' }, @@ -397,13 +402,18 @@ export const ASSESSMENT_STEPS = [ * 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单 * 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**: * 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍 - * 原样命中、照样幂等跳过,不会覆盖人刚填的内容。`prune:true` 清掉的只是「先前选了 - * 某个类型、后来改选了另一个」时那些不再相关的陈旧行。 + * 原样命中、照样幂等跳过,不会覆盖人刚填的内容。 + * + * **同样不带 `prune`**(与 ② 同一处修正,同一个理由:字段清单也在 ⑥ 的 + * `gateLedgers` 上,人交回数据工单那一刻就会跟着落地;已落地的行 `prune` 撤不掉, + * 带上 `prune:true` 会在「先前定过某个类型的字段清单、后来改选了另一个类型」这条 + * 路上原样复现 H35 e2e 案例 2 那个死锁)。历史选型的陈旧字段行留在草稿层不算错, + * 与 ② 的字典行是同一条道理。 */ { kind: 'project' as const, into: 'parts.type_param', - /* 同上一笔同一个理由,同一次修正 —— 见 ② 那笔头上的大注释,不重复贴 */ + /* 同上一笔同一个理由,同一次修正(含"不带 prune") —— 见 ② 那笔头上的大注释,不重复贴 */ from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } }, @@ -480,6 +490,20 @@ export const ASSESSMENT_STEPS = [ /* * ⑥ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑦)。 * + * **第一档改口(2026-08-30 修 Critical 2)**:两个启动参数现在是**必填** + * (`contract.input.required`),`missingRequired` 挡在起单那一刻 —— 空字符串永远 + * 到不了这一步。`sub_eval_part_rows < 2` 因此只剩**一条**能触发它的真实路径: + * `originalInternalPn === candidateInternalPn`(人把两颗料填成了同一个号)—— + * ① 那一笔 `project` 用它们铸 `sub_eval.part` 的主键 `internal_pn`,两行撞成同一个键, + * 第二笔 `draftWrite` 覆盖第一笔,表里只剩一行。旧文案「原料号与候选料号还没填全 —— + * 在下面把两格补上」在这条残余路径上是**两处都错**:①两格根本不是空的(必填墙已经 + * 拦住空值),说「没填全」是失实;②块① 的行源是 `mine:sub_eval.part`、人不加行 + * (没有「+加一行」按钮),即便真缺一行也补不出来 —— 那句「补上」指向一条不存在的路 + * (墙要配路,这句连墙都不是墙)。改口直说实情与唯一出路:两颗料号是这件事的身份, + * 填成同一个号没法比出差异,只能终止重开——这是**真正没有其他出路**的场景(与 + * 「留空」不同,「重复」没有任何一步能在本单内部把两个身份重新分开),不算失职地 + * 指错方向。 + * * **排在 ④⑤ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正): * `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`, * 而这两个数是 ④⑤ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ④⑤ **之前** @@ -502,7 +526,7 @@ export const ASSESSMENT_STEPS = [ chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' }, gate_reason: { when: 'sub_eval_part_rows < 2', - then: '原料号与候选料号还没填全 —— 在下面把两格补上。', + then: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。', else: { when: 'sub_eval_part_exists_status_absent > 0', then: '有料号库里查不到 —— 在下面把它的主数据建起来。', @@ -650,8 +674,10 @@ export const ASSESSMENT_STEPS = [ }, /* * ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误 - * (2026-08-30,真跑 e2e 第一次撞见的循环依赖,`docs/ACT-WORK-05-端到端测试记录.md` - * 追加记录):`substitution.difference_item.substitution_relation_id` 声明了 + * (2026-08-30,真跑 e2e 第一次撞见的循环依赖;**不是**记进 + * `docs/ACT-WORK-05-端到端测试记录.md`——那份文档整篇是四步重写[2026-08-29]之前 + * 的旧九步设计,已在文档开头标为历史、没有再追加过): + * `substitution.difference_item.substitution_relation_id` 声明了 * `refersTo: 'substitution.relation.substitution_relation_id'`(要求引用的那一行 * 已经真实存在),而 `substitution.relation` 的 `recommendation_level`/`risk_level`/ * `difference_summary`/`recommendation_reason` 这几列**要读差异明细的 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index 1701cb0..a753765 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -9,12 +9,12 @@ * 「本来就该是确定的」那几步(编号抄别单、判在库判错、数据工单派给在库的料…), * 真正需要判断的只有一件事:**这一条参数,候选跟原厂比算不算事**。 * - * 这份清单是 Task 2 的产物,只动 `declarations.ledger` 与 `contract` 两段,以及 `assets` - * 里已经不自洽的那几处(spec §8 的退役清单前移到这一步,见「计划裁定 G3」)。**案例模型 - * (`case-model.ts`)还是旧的九步、旧的 `guide` 文本** —— 下一个任务(Task 3)才整体重写成 - * spec §4 的四步(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`)+ 四道哨兵 + 零 `guide` - * (行为改由 `PlanStep.auto` 声明式配方接管)。旧剧本引用本次删掉的台账地址而导致的包内自测红, - * 已按台账逐条 `it.skip` 并注明「Task 3 会补回」,不删测试(§12.5 铁律)。 + * **2026-08-30 更新**:这份注释原写于重写 Task 2(只动 `declarations.ledger` 与 + * `contract` 两段那一刻),彼时 `case-model.ts` 还是旧九步——**Task 3 已把它整体重写成 + * spec §4 的四步**(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`)+ 四道哨兵 + + * 零 `guide`(行为改由 `PlanStep.auto` 声明式配方接管),清单与案例模型此刻同版一致, + * 不再有「一边新一边旧」的过渡态。`assets` 里当时已不自洽的那几处(spec §8 的退役清单, + * 见「计划裁定 G3」)同批处理完毕。 * * ## 九张台账声明(spec §3) * 两张新字典(`parts.chip_type` / `parts.type_param`,Task 1 已物化 + 播种)承接「约束类型按类型 @@ -54,7 +54,7 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, name: 'ACT-WORK-05 · 差异评估', /* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */ - packageVersion: '2.1.8', + packageVersion: '2.1.9', category: '需求分析与方案匹配', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-part-substitution-assessment', @@ -66,21 +66,41 @@ export const manifest: ActivityManifest = { version: '1.0.0', input: { /* - * 两格,都选填(spec §4.1):库里有就选,没有就自己写一个新的 —— 启动后由第一步的 - * 配方核对存在性、缺了就派数据工单补齐。运营端启动表单渲染这两格为 - * 「选择 + 新增」下拉(`x-control: combo`),候选从 `parts.profile.internal_pn` - * 现读(`x-options`);`x-order` 定表单次序(清单存在 jsonb 里,书写顺序到不了运营端)。 - * requestDescription / candidateInternalPns(数组)/ datasheets 三键已随重写撤下 —— - * 这一版没有「需求主记录」「N 候选并行」「预填 datasheet」这几件事。 + * **两格,都必填**(2026-08-30 修 Critical 2,推翻 spec §4.1 原来的「都选填」)—— + * 一次评估恰是「一对一候选」,这两颗料号是这件事的**身份**,不是可以事后补的细节: + * 库里有就选,没有就自己写一个新的(合法的新料号字符串,不是留空)。 + * 「启动后由第一步的配方核对存在性、缺了就派数据工单补齐」这句话说的是料号的**主数据** + * (品牌/类型/参数),从来不是料号**本身**——旧措辞把两件事含糊成了一件事。 + * + * 都选填时留空的下场(评审逐行核过的链条):S1 配方 `project into sub_eval.part` 拿 + * `originalInternalPn`/`candidateInternalPn` 铸两行的主键 `internal_pn`,两个空字符串 + * 是**同一个主键**——第二笔 `draftWrite` 覆盖第一笔,`sub_eval.part` 收成**一行**不是 + * 两行;`gate_reason` 的 `sub_eval_part_rows < 2` 当场为真,提示「在下面把两格补上」—— + * 而块① 的行源是 `mine:sub_eval.part`(人不加行,没有「+加一行」按钮),那两格根本 + * 不存在。`doneWhen` 永不成立,提交也按不下去,**唯一出路是终止重开**,且五条样例与 + * e2e 五案例都没填过空值,两条验证线都照不到这条死路。 + * + * 改必填**不是关上「留空」这扇门就完事**——平台早有配套机制接住它(2026-07-30 立的 + * 「受理归一」:缺必填不拒单,任务落成 `awaiting-human`,一张补全卡问负责人要那几个 + * 字段,答完合并进 `data` 才转 `running`;`apps/api/src/modules/task/internal/usecase/ + * task-service.ts` 的 `missingRequired`/`askCompletion`,`__tests__/intake.test.ts` + * 已经把这条路测得很实)。必填不是墙,是把「两颗料号是什么」这件唯一要人先说清楚的 + * 事挪到起单那一刻问清楚,不留给配方去猜一个猜不出来的空字符串。 + * + * 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从 + * `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在 + * jsonb 里,书写顺序到不了运营端)。requestDescription / candidateInternalPns(数组)/ + * datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填 + * datasheet」这几件事。 */ data: { type: 'object', - required: [], + required: ['originalInternalPn', 'candidateInternalPn'], properties: { originalInternalPn: { type: 'string', title: '原内部料号', - description: '差异评估基准料号;库里有就选,没有就自己写一个新的,启动后由第一步派数据工单补齐。', + description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。', examples: ['LM2596S-5.0'], 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }, @@ -89,7 +109,7 @@ export const manifest: ActivityManifest = { candidateInternalPn: { type: 'string', title: '候选内部料号', - description: '被评估的候选料号;库里有就选,没有就自己写一个新的,启动后由第一步派数据工单补齐。', + description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。', examples: ['XL2596S-5.0'], 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' }, @@ -180,8 +200,11 @@ export const manifest: ActivityManifest = { predicate: 'validation_record_rows = 1 and validation_record_missing = 0', }, /* - * **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现,记入 - * `docs/ACT-WORK-05-端到端测试记录.md`)——「裁量项带谓词时谓词说了算」这条纪律 + * **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现 —— **不是**记进 + * `docs/ACT-WORK-05-端到端测试记录.md`:那份文档整篇是 2026-08-27 及更早的旧 + * 九步设计,四步重写[2026-08-29]之后没有再往里追加过任何一条,已在文档开头标为 + * 历史;这条发现连同其余 2026-08-30 的都只活在这份源码注释与 `docs/debt.md` 里)。 + * 「裁量项带谓词时谓词说了算」这条纪律 * (2026-08-02 立,`engines.ts` 的 `criterionCheck`:`settledByData`)本仓已有先例, * 从前 W05 这两条 L4 是裸的(无 `predicate`),而 T_release 全程 `auto` 驱动、 * `submit` 是五个声明式动词之一(`packages/contracts/src/auto-recipe.ts`), @@ -505,7 +528,7 @@ export const manifest: ActivityManifest = { /* * DMN 判定表已退役:D01(逐参数四态)改由 S2 唯一一次 analyze 判定;D02(推荐等级)的规则 * 搬进配方的 when 链(spec §3.5)—— 规则只许有一处真源,再留一份 DMN 就是同一件事两处 - * 各存一份。Task 3 会把 `steps` 换成四步、`guide` 全部清空(auto 配方接管行为)。 + * 各存一份。`steps` 已是四步、`guide` 全部清空(auto 配方接管行为,Task 3 完成)。 */ businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES }, /* 三条技能已随重写退役(全程无 agent,没有临场编排可言);skills.ts 已在 Task 10 删除 */ diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index 085c788..0ea055c 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -330,9 +330,32 @@ const PART_TABLE = `
/** * 块②a 新建芯片类型 —— `parts.chip_type`(Task 7b,2026-08-29 补齐)。类型下拉(块①)里 * 没有合适的,人在这里现造一个:类型名称(必填)+ 说明(建议)。`type_id` 是自增主键 - * (`auto:true`,声明改 `text` 的理由见 manifest 那条大注释)—— **不进表单**,自增列 - * 不问人(与联系人编码 `contact_code` 同一条纪律,2026-08-02 立);只读态摆 `ro()`, - * 连孪生输入框都不给。 + * (`auto:true`,声明改 `text` 的理由见 manifest 那条大注释)—— **人不填它**,自增列 + * 不问人(与联系人编码 `contact_code` 同一条纪律,2026-08-02 立);已有行只读态摆 `ro()`。 + * + * **种子行里 `type_id` 必须挂一个真实的(禁用、恒空)`data-col`,不能干脆不给 —— + * 这是 2026-08-30 终审 Critical 1 的病灶与修法**(链条评审逐行核实过,详见 + * `docs/debt.md`「auto:true 又是整数型的主键」条目的姊妹坑):第一版图省事,把这一格 + * 写成纯文本 `系统生成`,不带 `data-col` —— 保存时 `collect()` 只认 + * `input[data-col]/select[data-col]/textarea[data-col]`,`type_id` 于是**压根不出现在 + * 回传的 `cells` 里**。而 `desk.ts` 的 `resolveRowKey` 用 `k in cells`(不是「值是否非空」) + * 判断「这一列人碰过没有」——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」, + * 专门为「auto 列件上摆着却空着」设计的 `barred` 保护(`(input.autos??[]).filter((k)=>k in + * cells && ...)`)因此从未触发,退回给「全表一致」那条兜底路径。而 Task 7b 把 + * `type_id` 从 `integer` 改成 `text` 之后,「序号型主键不走全表一致」(2026-08-23 那条 + * 保护,认的是 `isSeq`)也顺带失效 —— 两条保护同时缺位,「全表一致」直接接管: + * `parts.chip_type` 此刻**恰好一行**(Ruling H35 的自投影②保证了这一点), + * `seen.size===1` 当场把人新建的类型判成「就是那唯一一行」,一次保存就把种子字典行 + * (如 `type_id=1` 的「DC-DC 降压稳压器」)**静默改名**——`mintable`/`autoKeys()`(真正 + * 为它设计的续号器)永远轮不到。 + * **修法**:给这一格换成一个真的 ``(值恒空, + * 只显示「由系统生成」占位)——`'type_id' in cells` 从此为真、值为空,`barred` 正确 + * 命中,`resolveRowKey` 交回 `undefined`,`mintable` 才轮得到、续出一个不撞任何既有行 + * 的新号。**平台侧 `resolveRowKey` 本身一行没改**——这是活动侧绕开,`isSeq`/「全表 + * 一致」两条平台保护的根治仍记在 `docs/debt.md`(下一个建「单一主键又是 auto 列」的 + * 活动仍会踩,除非那两条也一并治了)。回归钉在 `__tests__/activity.test.ts` + * (报告层的 markup)与 `apps/api/src/app/__tests__/view-table-edit.test.ts` + * (真实 `resolveRowKey` 的因果机制,两种 `cells` 形状各自的结果)。 * * **零行照样加得出第一行**(这是这一块存在的全部理由):`mine:parts.chip_type` 每单 * 开局是零行,`ViewContainer.addRow` 靠克隆 `tr:last-of-type` 添新行,零行时找不到可 @@ -381,7 +404,7 @@ const CHIP_TYPE_TABLE = `
- + diff --git a/apps/api/src/app/__tests__/view-table-edit.test.ts b/apps/api/src/app/__tests__/view-table-edit.test.ts index 1f11198..526024c 100644 --- a/apps/api/src/app/__tests__/view-table-edit.test.ts +++ b/apps/api/src/app/__tests__/view-table-edit.test.ts @@ -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: '这是什么' }, // 与修复后 的产出一致 + drafts: ONE_EXISTING, + types: TYPES, + autos: ['type_id'], + }); + expect(out).toBeUndefined(); + }); +}); + describe('提交闸:必填没填就交不了', () => { const gate = (rows: Array>) => gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]); diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts index 75426f7..8108001 100644 --- a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -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); + }); }); diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index 8fa0c1c..51359cb 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -431,6 +431,50 @@ function fieldsCycle(fields: Readonly>): 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>): readonly string[] | undefined { + const entries = Object.entries(lets); + const names = new Set(entries.map(([name]) => name)); + const deps = new Map( + 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}]`; diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts index 5788850..2774b65 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-order.test.ts @@ -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>) => orderFields(fields).map(([name]) => name); +/** 同上,给 `orderLets` 用 */ +const letNames = (lets: Readonly>) => 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 = { + 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 = { + 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 = {}; + 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 = { + 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 = { + 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 = { b: { in: 'x' }, a: 'literal', c: { in: 'y' } }; + expect(letNames(lets).sort()).toEqual(['a', 'b', 'c']); + }); + + it('只认同一个 let 对象内的名字 —— {in:X} 引用起单入参 / case: / 基座读数这类全局名字不算依赖,不参与排序', () => { + const lets: Record = { + a: { in: 'originalInternalPn' }, + b: { in: 'case:openedAt' }, + }; + // 两条互不相干,不该抛错,数量对得上即可 + expect(letNames(lets).sort()).toEqual(['a', 'b']); + }); + + it('真正的环({in:X} 互指):抛 AutoFieldCycleError,话里点名是 let(noun 参数),不默默给错答案', () => { + const lets: Record = { 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 = { 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]]); + }); +}); diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index 673cbce..89e5490 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -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(fields: Readonly>): Record { + const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)); + const out: Record = {}; + for (const key of keys) out[key] = fields[key] as T; + return out; + } + + /** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */ + const LETS: Record = { + originalStamp: { in: 'caseSeed' }, + tag: { cat: [{ in: 'originalStamp' }, '-X'] }, + }; + + it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => { + expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']); + }); + + function harness(seed: Record) { + const drafts = new Map; patch: Record }>(); + 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; patch: Record }) => { + 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) => 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): 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 = { 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'); + }); +}); diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index b15edc6..872738f 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -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 }) => { 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)'); + }); }); /** diff --git a/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts b/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts index da4f327..00a8b3a 100644 --- a/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts +++ b/apps/api/src/modules/loop-engine/internal/domain/auto-order.ts @@ -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>): 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>): ReadonlyArray { + const entries = Object.entries(lets); + const names = new Set(entries.map(([name]) => name)); + const deps = new Map( + 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); +} diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 6bc3456..4b89375 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -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 = {}; - 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 */ diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index bb83588..4b1a6dc 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -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) { diff --git a/docs/ACT-WORK-05-端到端测试记录.md b/docs/ACT-WORK-05-端到端测试记录.md index bee88ff..a3b7a8a 100644 --- a/docs/ACT-WORK-05-端到端测试记录.md +++ b/docs/ACT-WORK-05-端到端测试记录.md @@ -1,5 +1,24 @@ # ACT-WORK-05 差异评估 —— 端到端测试记录 +> **历史记录,已停止更新(2026-08-30 标注)**:这份文档记的是**旧九步案例模型** +> (`S0_bind` / `S0d_p08_master` / `S0d_p10_document` / `S1_input_confirm` / +> `S2_normalize` / `S3_assess` / `S4_review` / `S5_prepare_landing` / `T_release`) +> 从零到 released 的走查账,时间跨度到 2026-08-27 为止。**2026-08-29 的整体重写** +> (Task 3,`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §4) +> 把九步砍成了四步(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`,行为改由 +> `PlanStep.auto` 声明式配方接管,ADR-014)——**下面九个计划项的表、逐轮走查、A–Z +> 发现表,描述的都是这个已经不存在的旧结构**,不代表当前 `case-model.ts` 的真实形状。 +> 保留只为存档:六轮浏览器走查与脚本 e2e 逼出的 26 条发现(A–Z)里,相当一部分 +> (措辞纪律、`refersTo`/`requiredWhen` 这类结构治法、「机检只读基座数」的又一次违反) +> **后来被折进了四步重写的设计与 `docs/debt.md`**,不是白跑的,只是不该再被当成 +> 「这个活动现在长什么样」的说明书来读。**四步重写之后的发现不再追加进这份文档**—— +> 散落在源码注释(`case-model.ts`/`manifest.ts` 的大注释)、`docs/debt.md`、以及 +> `.superpowers/sdd/2026-08-29-act-work-05-rewrite/` 下的过程记录里。 +> +> 以下原文未删改。 + +--- + > 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。 > 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。 > 台账字段口径见 `docs/数据域字段说明-W05.md`。 diff --git a/docs/adr/ADR-014-deterministic-steps.md b/docs/adr/ADR-014-deterministic-steps.md index 0812545..62d5447 100644 --- a/docs/adr/ADR-014-deterministic-steps.md +++ b/docs/adr/ADR-014-deterministic-steps.md @@ -198,7 +198,12 @@ type AutoExpr = | { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套) ``` -**字段按声明顺序求值,已算出的字段进同一行的 S-FEEL 作用域** —— 「先 lookup 出依据,再按依据判 found/absent」 +**字段按依赖拓扑序求值,不按声明/存储顺序**(2026-08-30 全分支终审 Important 1 改写此段 —— +`fields` / `let` 存进 `sys.activity_package.manifest` 这个 jsonb 列之后,对象键的原始书写顺序 +不再保证,PG 会按「先短后长、同长字典序」重排;`orderFields`/`orderLets` +按字段名之间的真实依赖排出一个安全序,真正的环在装包静态审那一关就被拒装,不留到运行时 +静默算错。见 `apps/api/src/modules/loop-engine/internal/domain/auto-order.ts`), +已算出的字段进同一行的 S-FEEL 作用域 —— 「先 lookup 出依据,再按依据判 found/absent」 这种写法靠它成立。 ### 4.1 例:S0d_p10_document(整步确定,最小的那个) diff --git a/docs/debt.md b/docs/debt.md index b2a3b69..11a32bd 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1277,6 +1277,101 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人 但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。 **现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。 +## `modules → app` 是全仓第一条这个方向的 import(2026-08-30,Ruling H9,Task 4/7b 装 `@app/*` 别名时记的) + +`apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 与 +`w05-s3-review-recompute.test.ts` 两份测试用新增的路径别名 `@app/*`(`tsconfig.base.json` + +`vitest.config.ts` 各加一行,与既有 `@modules/*`/`@shared/*`/`@features/*`/`@web-shared/*` +同一种写法)直接 import `apps/api/src/app/wiring/engines.ts` 的具名导出(`rowReadings`/ +`missingReadings`/`tallyReadings`/`distinctReadings`)——按本仓的模块层级(`docs/编码规范.md`: +`apps/api/src/app/` 是「唯一组装层」,`modules/*` 是被组装的那一层,依赖方向只许 +`app → modules`),这是**全仓第一条 `modules → app`** 的 import,且目前只出现在测试文件里 +(production 代码里一条都没有)。 + +**为什么会有它**:这两份测试要把真 `createAutoRunner` 跑起来验证「引擎逐 action 刷新 +时机」这类只有真求值才测得出的回归;读数层四个函数(`missingReadings` 等)只在 +`engines.ts` 里有实现,第一版手抄了一份副本,**同一笔提交里就漂了**(`missingReadings` +拼缺项名的真身多带一句 `label ?? `,手抄件漏掉)——「手抄会漂」这个仓已经踩过三次 +(读数函数 / `let` 镜像 / 选择器复刻),这次选择 import 真身而不是再抄一遍。 + +**要治**(方向,未裁定,是全仓架构决定不是单个活动包能裁的): +- `.dependency-cruiser.cjs` 现在只有 `packages-are-leaves`(禁 `packages → apps`)管这类 + 方向,`modules → app` 完全不受约束——该不该补一条规则(禁 `modules → app`,但要给 + 测试文件开口子,否则这条测试会被打回手抄、重蹈「手抄会漂」的覆辙),需要专门讨论。 +- 或者认为「测试文件例外」本就是可接受的常态(`packages/ui-kit/__tests__/ + view-container-collect.test.ts` 已有 `apps → packages` 方向的先例),把这条也当先例 + 记下就够,不必新增规则。 + +**排查法**:`grep -rn "from '@app/" apps/api/src/modules` 排除 `__tests__` 目录, +确认这个方向至今只活在测试里。 + +## `` 排在 `` 之后:违反 HTML5 表格内容模型、读屏器列头关联失效(2026-08-30,Ruling H31) + +`activities/part-substitution-assessment/src/views/report.ts` 的 `CHIP_TYPE_TABLE` / +`TYPE_PARAM_TABLE`(Task 7b 修「新建行按钮克隆到表头」那次 Critical 回归)把 `` +写在两个 `` 之后——**挪位本身是那次修复的正确解法**(`data-edit` 两处写法让 +`ViewContainer.bodyOf()` 解析到外层 `
系统生成
`,`addRow()` 在这个 host 里找 +`tr:last-of-type`;`` 若排在数据行前面会被优先命中、克隆出表头文字而不是数据格), +但这个修法本身留了一笔新账: + +- **违反 HTML5 的 table 内容模型**:标准写法是 `` 排最前(`` 可以排在 + `` 之后、`` 之前,但 `` 本身不许排在 `` 之后)。 +- **会让读屏器的自动列头关联失效**:无障碍算法按**文档序**从数据格向上扫、找同列的 + `` 排在最后时这条自动关联找不到表头。 + +呈现不受影响(CSS `table-header-group` 让 `` **视觉上**永远排最前、不看它在 +DOM 里排第几,`querySelector`/`:last-of-type` 认的却是文档序——呈现与选择器认的不是 +同一回事),走查看不出来,只有读屏器 / 标记校验器能测出来。复审当时判断:本仓从未 +提出过可访问性(a11y)要求,故非违反既有标准、是此前不存在的隐性缺口;真要治得动 +`bodyOf`/`addRow` 或重构成单个 ``,都超出了那次修复「零平台改动」的前提。 + +**要治**(方向,未验证,留给认真做无障碍的时候一起评估): +- 平台侧治本:`bodyOf`/`addRow` 改成不依赖 ``/`` 的相对顺序(直接按 + `data-rows` 标记的那个 `` 取,不用 `tr:last-of-type` 在整个 `
` 来读出「这一格是哪一列」,`
` 范围里找), + `` 就能挪回最前面,两全。 +- 或者把两个 ``(动态行 + 静态种子行)合并成一个,`` 天然留在最前——但 + 种子行「不受 `data-rows` 管辖、行数再少也在」这条设计意图需要另一种表达方式。 + +**影响面**:目前只有这两张表这么写(其余活动的可编辑表 `` 都在最前,因为它们 +没有「零行也要有种子行」这个需求、`data-edit` 也没有写两遍)。 + +**排查法**:`grep -n '' activities/*/src/views/*.ts`,看它是不是排在最后一个 +`` 之后——目前全仓只有这两张表命中。 + +## `data-by` 嵌套查表:父键在本行里缺席时会悄悄落回外层同名字段(2026-08-30,终审 I3,浏览器真人走查缺陷①逮到) + +**现象**(ACT-WORK-05 task-12,浏览器走查):数据工单件上,候选料那一行的「芯片类型」 +只读态**显示着一个值**,而红带同时说它「缺芯片类型」——编辑态与直接查库都核实过 +那一格确实是空的,屏上两处自相矛盾。 + +**病根**:`apps/api/src/modules/interaction/internal/domain/view-render.ts` 的 +`renderRows`(:191)递归处理嵌套 `data-rows` 时用 `{ ...scope, ...one }` 合并「外层 +作用域」与「本行」——`one`(本行)**没有**的字段会悄悄落回 `scope`(外层)里同名的 +那一个。这个「悄悄落回」平时无害(未填的草稿行缺一个键,落回外层同名字段多半也是 +空的),但当外层作用域**恰好**有一个同名、非空的字段时,`data-by` 嵌套查表 +(`byParent`,:201)会拿这个不该被拿到的值去查表,查出一个不属于这一行的结果。 + +ACT-WORK-05 恰好撞上这个「恰好」:`engines.ts` 的 `taskData.view()` 会把「各对象最新 +一行的字段名」铺到顶层(`task.data`),而 `chip_type_id` 这个名字同时是 `sub_eval.part` +/ `sub_eval.case` / `parts.profile` 三张表的列名——`case-model.ts:134` 那条注释早就为 +同一个撞名问题给 S-FEEL 求值加过护栏(「这个名字在三张表上都有,顶层同名以后写的赢, +改用对准一张表的 `lookup`,不歧义」),但那条护栏只保护了 `case-model.ts` 自己的配方 +求值,没有覆盖到 `view-render.ts` 这一侧的 `data-by` 嵌套查表——同一类撞名在这里 +原样重演。 + +**已钉进套件、未修**(`apps/api/src/modules/interaction/__tests__/view-render.test.ts:460` +的 `it.fails`,已用最小复现核对过不是测试自己写错):这是共享面(E04/I01 也用 +`data-by`),改动需要先报给设计者裁定修法,当时只坐实、不改 `view-render.ts`。 + +**要治**(方向,未验证):`renderRows` 合并作用域时,`one` 里**没有**的键不该落回 +`scope` 同名字段——要么显式置 `undefined`(阻断落回),要么 `byParent` 取父键值时 +只认 `one`(本行)自己有没有这个键,不看合并后的结果。两条都要过现有测试(「父值 +缺席一行不留」那条已经钉死了「本行显式给出空字符串」这个不同的场景,不能被误伤)。 + +**排查法**:嵌套 `data-by` 查出来的值与红带/编辑态对不上,先怀疑这条——尤其当查表用 +的那个列名在**多张表上撞名**、且当前这一行**没有**这个字段(不是空字符串,是键都不 +存在)时。 + ## 运营中心 · 活动模块接口(2026-08-27 落地时记下的) - **实例清单的过滤 / 分页在内存里做**:`listInstances` 全量取该活动的 `sys.task`,非终态行逐单读记忆与 @@ -1500,6 +1595,79 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut `type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的 号从 `1` 起,最容易撞上种子数据里最早的几行)。 +## `resolveRowKey` 的「全表一致」没有排除 `auto` 列 —— 第二形(2026-08-30,全分支终审 Critical 1) + +上一条记的是 `type:'integer'` 那一形(§③ 续号步骤抢跑);把 `type_id` 改成 `text` 绕开它 +之后,**同一个函数的另一段**(`resolveRowKey` §② 「全表一致」,`apps/api/src/app/routes/ +desk.ts:144-168`)在**一个更窄的组合**下现出第二形:一张表的主键**唯一**一列就是 +`auto:true`(没有别的非 auto 主键列陪衬,如 W05 的 `parts.chip_type.type_id`)。 + +**病灶链条**(`part-substitution-assessment@2.1.8` 的 `views/report.ts` 曾经这样写): +视图把这一列的种子行(件上「新建一行」时用来兜底提交的那一条静态行)干脆不给 `data-col` +(想法是「自增列不问人,索性连格子都不摆」)——`ViewContainer.collect()` 只认 +`input/select/textarea[data-col]`,这一列于是**压根不出现在回传的 `cells` 里**。 +`resolveRowKey` 的 `barred` 判断认的是 `k in cells`(这一列在回传里露没露过面),**不是 +「值是否非空」**——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」,专门为「auto 列 +件上摆着却空着」设计的 `barred` 保护(:135)因此从未触发,`known` 退回 +`findKnownRow`(对空 `key` 恒返回 `undefined`),`isSeq('type_id')` 因为声明是 `text` +也是假 —— 两条已有保护都不成立,直接落进「全表一致」:表里此刻**恰好一行**(活动侧 +的自投影机制保证了这一点,是巧合但不是意外)时,`seen.size===1` 把新建的那一行判成 +「就是那唯一一行」,一次保存就把既有那一行的其余字段**静默覆盖**。 + +**这次只在活动侧绕开**(给种子行的这一格换一个真的、`disabled` 的 ``,值恒是空字符串——`barred` 就会正确命中,交回 `undefined` 让 +`mintable`/`autoKeys()` 接手续号),`resolveRowKey` 一行没改,见 `views/report.ts` 里 +`CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts` +「Critical 1」那组用真实函数逐字重放的两种 `cells` 形状。 + +**要治**(与上一条「§③ 续号加 `if (input.autos?.includes(k)) continue;`」是**同一刀**, +建议一起做):`resolveRowKey` 的 §②(全表一致循环,:144-168)在**进入这个 `for` 循环给 +某一列赋值之前**,先判 `input.autos?.includes(k)` 就直接 `continue`(与 §③ 那条建议 +补的守卫完全对称)。这样一来,声明了 `auto` 的列会**无条件**被这两段循环跳过 —— +不再依赖视图是否记得给它挂一个「看不见但露面」的 `data-col`;§① 那段「件上摆了、人也 +填了」的正常改行路径不受影响(它在这两段循环**之前**就已经把值定下来,轮不到 §②③)。 +补了这一刀,`barred` 这个专门的旁路判断其实就可以退休(它存在的唯一理由就是在 §②③ +没有这条守卫时,给「视图记得开个空位」这种个例开个后门)——但退休 `barred` 是更大的 +改动,不必和这次的守卫补丁绑在一起做。 + +**排查法**:与上一条同一句判词,但**不要求** `type:'integer'`——只要该表的主键里 +`auto:true` 的那一列**是唯一的主键列**(没有别的非 auto 主键列陪衬,靠不上 +`findKnownRow` 的「至少一格对得上」),且视图的种子/新增行**没有**给它挂 `data-col`, +就该怀疑这条坑,不论声明的类型是 `integer` 还是 `text`。 + +## 范围放行(`releaseWrite` 的 `only` 形)没有对 `refersTo` 做传递闭包(2026-08-30,ACT-WORK-05 e2e 撞见,H35 落地时在活动侧绕开) + +**现象**:「数据工单确认即入库」(`apps/api/src/app/data-order-landing.ts`,2026-08-27) +把**这张卡的提交闸盯着的那几张表**(`dataOrderLedgers` 算出的 `only` 名单,取自卡绑的 +`blocks` 或它自报的 `gateLedgers`)并进正式表(`ledger.releaseWrite(taskId, +{ only, cause })`)——只并名单上的表,不看这些表**引用**着哪些别的表。 + +如果 A 表(如 `parts.profile`)在 `only` 名单里、被并进正式表,而 A 表某一列声明了 +`refersTo: 'B.x'`(如 `parts.profile.chip_type_id` 指向 `parts.chip_type.type_id`), +B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留在草稿层,**正式表里就会 +出现一条引用着「只在草稿层存在、正式表查无此号」的悬空引用**:下一个任务(或同一个 +任务的下一步)在正式表侧查 B 表,查无此值,`refersTo` 校验拒它,或 `data-by` 嵌套 +查表显示不出来。 + +**这一次没有真的炸出悬空引用**——`part-substitution-assessment` 在活动自己的声明层 +把 A/B 两张表都放进了同一张卡的 `gateLedgers`(Ruling H35,`case-model.ts` 那一步 +`card` 定义处的大注释),绕开了这个触发点。但那是**这一个活动的选择**,不是平台 +保证:下一个活动如果只把「主表」列进某张卡的 `gateLedgers`、忘了把它引用的「字典表」 +也列进去,同样的悬空引用会在**正式表**里真实出现,不是测试环境才有的问题。 + +**要治**(方向,未验证):`releaseWrite` 的 `only` 形在计算真正要并的表集合时,应该 +对 `only` 名单里每张表声明的 `refersTo` 做一次**传递闭包**——A 引用 B、B 又引用 C, +落地范围就该自动扩成 {A, B, C},而不是原样信任调用方给的 `only` 名单已经把引用链 +配全了。 + +**同族**(下一条,紧接在后):都是「数据工单确认即入库」这个 2026-08-27 新加的机制, +与**别的**既有机制(这一条是 `refersTo` 的传递闭包,下一条是 `prune`)之间没有对齐 +接缝造成的。 + +**排查法**:数据工单确认之后,别的任务查某张「被引用」的表查无此值(而草稿层里其实 +有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它 +`refersTo` 指向的「字典表」。 + ## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见) **现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型), @@ -1525,9 +1693,9 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut 撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**: `prune` 想撤、平台不让撤。 -与 2026-08-30 已记的另一条(**范围放行没有对 `refersTo` 做传递闭包**,见「ACT-WORK-05 走查 -逼出来的三条平台欠账」附近的记录)同族——两条都是「数据工单确认即入库」这个 2026-08-27 -新加的机制与**别的**既有机制(`prune`、`refersTo` 的传递闭包)之间没有对齐接缝造成的。 +与上一条(**范围放行没有对 `refersTo` 做传递闭包**)同族——两条都是「数据工单确认即 +入库」这个 2026-08-27 新加的机制与**别的**既有机制(这一条是 `prune`,上一条是 +`refersTo` 的传递闭包)之间没有对齐接缝造成的。 **要治**(方向,未验证,留给真正动手时评估): - 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于 diff --git a/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md b/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md index 9546cdf..9a5f04c 100644 --- a/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md +++ b/docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md @@ -261,6 +261,17 @@ doneWhen sub_eval_part_exists_status_found = 2 - 人点「提交」= 数据工单确认 → **`chip_type` / `type_param` / `profile` / `parameter` 当场进正式表** (平台 2026-08-27 的范围放行)。它们是主数据,该立刻可用,不等整单放行。 +> **2026-08-30 补记**:实现是**八笔**,不是上面写的六笔 ——「提交」那一句已经点出原因: +> `chip_type` / `type_param` 要跟着数据工单一起进正式表(范围放行只并 `gateLedgers` +> 名单上的表,见 `docs/debt.md`「范围放行没有对 `refersTo` 做传递闭包」),而它们此刻 +> 只在人新建类型时才有草稿行——挑一个已有类型时天然零行,闸拿「零行」误判成没干完。 +> Ruling H35(e2e 真跑撞见)在②③之间插了两笔**自投影**(原样把选中的类型字典行 / 字段 +> 清单行抄回自己身上,值不变、幂等,唯一目的是让 `mine:` 不再天然为空),使这两张表 +> 能安全回到 `gateLedgers` 上。次序变成:①两颗料 → ②类型字典自投 → ③字段清单自投 → +> ④主数据骨架 → ⑤参数骨架 → ⑥案卷 → ⑦卡 → ⑧交代(与上面①③④⑤⑥对应,原④⑤⑥ 顺移成 +> ⑥⑦⑧)。这两笔**不带 `prune`**(与③节 §11.4 那条改口同一个理由:字典/字段清单是共享 +> 的,历史选型的陈旧行留着不算错)。详见 `case-model.ts` 该两笔头上的大注释。 + ### 4.3 S2 差异分析 —— 纯系统 + AI 唯一一次出场 ``` @@ -313,14 +324,22 @@ doneWhen substitution_difference_item_rows > 0 ### 4.4 S3 结论审核 —— 一张验收工单,人就地改 -配方只有一笔:`card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一选项「确认通过」**, -带人工验收清单四条)。 +**2026-08-30 改口**:这一节写下时以为配方只有一笔(`card`),下一段自己就说漏了 —— +真正的配方是**五笔**:①差异明细**重算笔**(2026-08-30 task-12「结论纹丝不动」缺陷补的, +见下)→ ②③ 复制自 S2 的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`(同一个对象引用, +不是抄了两份)→ ④ `card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一 +选项「确认通过」**,带人工验收清单四条)→ ⑤ `say`。 -人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → S2 的第③笔把推荐等级、摘要、风险 -**重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。 +人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → **①重算笔**先把人改的 +`difference_status` 转译成 `judgement_class`/`risk_level`(这一步的存在理由:走查发现⑤ +逮到的真实缺陷——最初的设计只复制了②③,人改的四态没有任何一笔把它转成 +`judgement_class`,②③读到的 tally 纹丝不动,人在件上改了却什么都没变;`docs/debt.md` +已记「S3 存在的全部意义就是让人纠正 AI;纠正流不下去,S3 就是摆设」)→ ②③把推荐等级、 +摘要、风险**重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。 -> 重算发生在 S2 那一步的配方里,而 S2 已收步 —— 所以 S3 的配方里**复制一份 ③④ 两笔** -> (纯投影、幂等),不去回退步骤。这是刻意的:一步一份配方,不做跨步回跳。 +> ②③ 复制的是 S2 那一步配方**同一个对象引用**(纯投影、幂等),不去回退步骤 —— S2 已 +> 收步,S3 不能跨步回跳,只能在自己的配方里再摆一份同样的投影逻辑。这是刻意的: +> 一步一份配方,判定分类/风险等级的 `when` 链只许有一处真源(共用引用而非各写一份)。 这一步**不给 `doneWhen`**(它触达世界),人点了「确认通过」即收步。 @@ -499,7 +518,7 @@ tick | key | level | 谓词大意 | |---|---|---| -| `input-ready` | L1 | 两颗都在库 · 类型一致 · 参数行数 = 字段数×2 · 参数值不缺 | +| `input-ready` | L1 | 两颗都在库 · 类型一致 · 主数据与参数值都不缺必填(**不写**「参数行数 = 字段数×2」——2026-08-30 与实现一起改口:那不是基座数得出来的数,§4.2 doneWhen 尾注已经讲过同一条道理,这里当时漏改) | | `every-param-judged` | L2 | `substitution_difference_item_missing = 0` 且四态四档计数之和 = `_rows`(AI 漏一条 → 那行四态为空 → 两边都不成立) | | `level-by-rule` | L2 | 推荐等级与 `judgement_class` 计数一致(硬不符>0 必须是「不推荐」…) | | `unknown-has-disposition` | L2 | `substitution_difference_item_missing = 0` | @@ -574,7 +593,15 @@ tick 而判断正是人在 S3 要核的东西。 3. **类型字典会被建脏**(同一类型建两遍、字段名不一致)。件上下拉优先选已有、新建是次选; MVP **不做去重合并**,记 debt。 -4. **`prune` 会撤本单草稿层的行** —— 只在 S1 参数骨架这一笔用,别处不许用。静态审可以只放行这一处。 +4. **`prune` 会撤本单草稿层的行**(2026-08-30 改口:这一条与实现一起对照过 —— 实际用在**三处** + `project`,不是一处:S1 的 `parts.profile`(④ 主数据骨架)/ `parts.parameter`(⑤ 参数骨架)、 + S2 的 `substitution.difference_item`(②,§4.3)。§4.2/§4.3 的伪码从写下那一刻起就是这三处 + 都带 `prune`,这里当时漏改成了「只一处」。真正的约束不是「只许一处」,是**只许用在 + 「这一笔的数据源就是这张表在本单该有的全部」这一类骨架/明细投影上**——H35 后来新增的 + `parts.chip_type`/`parts.type_param` 两笔**自投影**(4.2 节之外,原样抄字典行那两笔) + 就**刻意不带** `prune`:它们写的是共享字典/字段清单,不是这一单独占的骨架,历史选型的 + 陈旧行留着不算错,带 `prune` 反而会在人改选类型后把已经落地的陈旧行撤成死锁——静态审 + 要放行的是「哪一类数据源」,不是「哪一个固定笔数」)。 5. **保存一次 = 一拍**。人反复保存十几次会吃步数,预算给到 40。 6. **表达力有上限**。上限之外的不要硬塞 —— 退回模型,或者把那一段切成独立的 agent 步。