Archived
fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口
**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。
**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。
**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。
**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。
**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。
debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。
包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 配方字段求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* `AutoAction.fields` / `analyze.input.fields` 存进 `sys.activity_package.manifest`
|
||||
* (`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:PostgreSQL 的 `jsonb` 按
|
||||
* 「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
|
||||
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
|
||||
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||
*
|
||||
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
|
||||
@@ -15,14 +15,35 @@
|
||||
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
|
||||
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
|
||||
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
|
||||
*
|
||||
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
|
||||
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
|
||||
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
|
||||
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
|
||||
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
|
||||
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
|
||||
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
|
||||
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
|
||||
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
|
||||
* 不是简单复用 `whenReferences`/`orderFields`。
|
||||
*/
|
||||
import type { AutoExpr } from '@contracts';
|
||||
import { staticCheck } from '@sfeel';
|
||||
import { topoOrder } from '@utils';
|
||||
|
||||
/**
|
||||
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
|
||||
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
|
||||
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
|
||||
*/
|
||||
export class AutoFieldCycleError extends Error {
|
||||
constructor(readonly cycle: readonly string[]) {
|
||||
super(`字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`);
|
||||
constructor(
|
||||
readonly cycle: readonly string[],
|
||||
noun: '字段' | 'let' = '字段',
|
||||
) {
|
||||
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
|
||||
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
|
||||
this.name = 'AutoFieldCycleError';
|
||||
}
|
||||
}
|
||||
@@ -80,3 +101,53 @@ export function orderFields(fields: Readonly<Record<string, AutoExpr>>): Readonl
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
|
||||
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
|
||||
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
|
||||
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
|
||||
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
|
||||
*
|
||||
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
|
||||
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
|
||||
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function letReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('in' in expr) return [expr.in];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
|
||||
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
|
||||
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
|
||||
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
|
||||
*/
|
||||
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||
const entries = Object.entries(lets);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const byName = new Map(entries);
|
||||
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
||||
*/
|
||||
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
|
||||
import { AutoFieldCycleError, orderFields } from '../domain/auto-order.ts';
|
||||
import { AutoFieldCycleError, orderFields, orderLets } from '../domain/auto-order.ts';
|
||||
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
|
||||
import { expandSource } from '../domain/auto-source.ts';
|
||||
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
|
||||
@@ -107,9 +107,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
|
||||
const condition = deps.sfeel.condition;
|
||||
const lets: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
/*
|
||||
* **`let` 算好之前不许任何一笔动作跑**(2026-08-30 终审 Important 1)—— 计算挪到了
|
||||
* 下面主循环第一次迭代的 try 里(见那一段注释),`letsReady` 只是标一下算过没有。
|
||||
*/
|
||||
let letsReady = false;
|
||||
|
||||
async function runAction(
|
||||
action: AutoAction,
|
||||
@@ -361,6 +363,25 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
dirty = false;
|
||||
}
|
||||
/*
|
||||
* **`let` 与 `fields` 同一个 jsonb 重排隐患,同一套治法**(2026-08-30 终审
|
||||
* Important 1,H47 当时只治了 `fields` 那一半 —— 见 `auto-order.ts` 头注)。
|
||||
* `{in:X}` 是 `let` 唯一的官方写法,后一条 let 靠它读前一条已经算出的值,
|
||||
* 键序被 jsonb 打乱同样会让这条依赖读到 `undefined`(未绑定的名字,不报错、
|
||||
* 静默算错)。改按 `orderLets` 的依赖拓扑序求值,只算一次(`letsReady`)。
|
||||
*
|
||||
* **放进第一次循环的 try 里,不是单独一段**——唯一目的是**复用同一个 catch**:
|
||||
* 真撞上环(该在装包静态审就被拦下,见 `static-audit.ts` 新增的 `letsCycle`
|
||||
* 检查,这里是最后一道兜底)交人的路子,与某一笔配方动作本身办不下去完全一样,
|
||||
* 不必另开一条"let 专属"的失败通道(`failedAt.index` 报的是 0,即"第 1 笔"——
|
||||
* 不完全精确,但 `failed.message` 里的原话已经说清是 let 算不出来,够用)。
|
||||
*/
|
||||
if (!letsReady) {
|
||||
for (const [name, expr] of orderLets(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
letsReady = true;
|
||||
}
|
||||
const base: ExprCtx = { scope, lets, condition };
|
||||
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
||||
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
|
||||
|
||||
@@ -159,6 +159,13 @@ const REDO_MARK = '验收被打回重做';
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
||||
const AUTO_FAIL_MARK = '配方办不下去';
|
||||
/**
|
||||
* 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 ——
|
||||
* 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支,
|
||||
* 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面
|
||||
* `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。
|
||||
*/
|
||||
const AUTO_SUBMIT_REFUSE_MARK = '提交放行被拒';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
@@ -2133,13 +2140,78 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
/*
|
||||
* **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。
|
||||
*
|
||||
* 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步
|
||||
* (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`,
|
||||
* `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个
|
||||
* `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因
|
||||
* (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈:
|
||||
* 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份
|
||||
* 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算,
|
||||
* 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记,
|
||||
* `stallAfterMs` 那道熔断因此永远不会提前触发)。
|
||||
*
|
||||
* **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的
|
||||
* 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次
|
||||
* 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对,
|
||||
* 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙:
|
||||
* 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式——
|
||||
* 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。
|
||||
*/
|
||||
const already = (await deps.memory.window(taskId, STALL_WINDOW)).some(
|
||||
(t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `提交放行被拒:${reason}`,
|
||||
title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
|
||||
});
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
if (!already) {
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
}
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
try {
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' +
|
||||
'也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' +
|
||||
'数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' +
|
||||
'判据或配方本身有问题就找开发改完重跑。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '拒回原话', v: reason },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
} catch (issueError) {
|
||||
const issueReason = issueError instanceof Error ? issueError.message : String(issueError);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`,
|
||||
detail: {
|
||||
auto: current.item,
|
||||
hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (auto.issuedCardId !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user