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
@@ -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)');
});
});
/**