fix(loop-engine): 配方失败给人一条真的路;幂等在「反复进入」下也成立

**I1 · 失败不再伪装成「等外部回音」**:从前配方办不下去只 `status:'awaiting'`
就走了 —— 映射成 `awaiting-event`,而推进器只捡 `running` 的单:卡已答完、
没立监听、没有时限,**再没有任何东西会叫醒它**。人在工作台上看到「等外部回音」,
而外面没有任何回音会来。现照熔断兜底卡的成例:发一张卡把办不下去那一笔的原话
摆给负责人 + 立人事件监听。走到那儿时这一单没有未答的卡(runTurn 开头
`cards.unanswered` 把住),所以不会堆卡。不补 narration —— 那句话 auto-runner
已落成系统观察进了对话区,而且停下来的是系统不是 AI(narration 是 AI 蓝)。

**I2 · 重投不许把已落地的行 un-land**:`DraftStore.put` 的 upsert 尾巴上有
`landed_at = null`,而配方每一拍都把同一张表重投一遍 —— 数据工单确认入库之后
那些行的「已落地」记号被悄悄擦掉,整单放行会再写一遍正式表、再抛一遍 data 事件。
新增两个**配方专用**旗(模型那一路一个字不变):`skipUnchanged`(值没变就不落笔)、
`skipExisting`(「只建不改」的表碰到已在库的键当「不用建」跳过并留痕,不抛 ——
那句拒回是写给模型看的,配方抛出去就是永久停单)。已在库的行也进 `kept`,
prune 不许撤它。

M4 一并做了:auto-runner 的假 `draftWrite` 从**覆盖**改成生产的**合并**语义
(`patch || excluded.patch`),并补上 landed / createOnly 两件生产真有的事 ——
「重投不会擦掉人填的格子」这条结论在覆盖语义下根本不成立,旧替身证明不了它。

I1 2 测、I2 5 测、M4 使既有 20 测在真语义下重跑。

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-29 08:27:40 +08:00
co-authored by Claude Opus 5
parent 6212b34e39
commit 198d12af1b
6 changed files with 303 additions and 14 deletions
@@ -42,7 +42,12 @@ function asLogEntry(captured: { kind: string; title: string; detail: Record<stri
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
const drafts = new Map<
string,
{ domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; landed: boolean }
>();
/** 「只建不改」的表里已经躺在**正式表**的键(`域.对象|键值…`)—— 生产由 `createOnlyViolation` 判 */
const formal = new Set<string>();
const notes: string[] = [];
/** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */
const appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }> = [];
@@ -60,11 +65,42 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
]),
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
/**
* **合并语义,不是覆盖**(2026-08-29 终审 M4):生产的 upsert 是
* `patch = 草稿.patch || excluded.patch`(`ledger-store.ts:369`)——
* 从前这个替身写成 `drafts.set(id, { patch })` 是**覆盖**,
* 「重投不会擦掉人填的格子」那条结论在覆盖语义下根本不成立,替身证明不了它。
*
* 同批补上生产真有、而替身从前没有的两件事:
* · 任何一次真落笔都把 `landed` 打回 false —— 生产的 upsert 尾巴上写着 `landed_at = null`;
* · 「只建不改」的表碰到已在正式表里的键:默认拒(抛),`skipExisting` 时跳过。
*/
draftWrite: (i: {
domain: string;
object: string;
key: Record<string, string>;
patch: Record<string, unknown>;
skipUnchanged?: boolean;
skipExisting?: boolean;
}) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
if (formal.has(id)) {
if (i.skipExisting === true) return Promise.resolve({ changed: false, skipped: true });
return Promise.reject(new Error(`${i.domain}.${i.object} 里已经有这一行 —— 这张表只许新建库里没有的记录`));
}
const before = drafts.get(id);
const changed =
before === undefined ||
Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
drafts.set(id, {
domain: i.domain,
object: i.object,
key: i.key,
patch: { ...(before?.patch ?? {}), ...i.patch },
landed: false,
});
return Promise.resolve({ changed });
},
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
@@ -99,7 +135,7 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
};
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
return { runner: createAutoRunner(deps as never), drafts, notes, appended, cards, ctx };
return { runner: createAutoRunner(deps as never), drafts, formal, notes, appended, cards, ctx };
}
describe('配方执行器', () => {
@@ -123,6 +159,7 @@ describe('配方执行器', () => {
object: 'part',
key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
patch: { role: 'original' },
landed: false,
});
});
@@ -444,3 +481,103 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', ()
expect(lines[0]?.who).toBe('system');
});
});
/**
* **幂等在「反复进入」下也得成立**(2026-08-29 终审 I2)。
*
* 配方每一拍都把同一张表重投一遍。而草稿层的 upsert 尾巴上有 `landed_at = null`
* (「再写一笔就不再是已落地」)—— 数据工单确认入库之后再重投,那些行的「已落地」记号
* 就被悄悄擦掉,整单放行时会被再写一遍正式表、再抛一遍 data 事件,
* 而 CLAUDE.md 对 landed 行的承诺是「整单放行不再写它、不抛事件」。
*/
describe('幂等 · 重投不许把已落地的行 un-land(I2)', () => {
const RECIPE: AutoRecipe = {
do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'original' } }],
};
it('值没变就压根不落笔 —— 已落地的记号原样还在', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(RECIPE, ctx);
// 数据工单确认入库:这一行打上「已落地」记号(生产是 `__draft.landed_at`)
const row = [...drafts.values()][0]!;
row.landed = true;
const again = await runner.run(RECIPE, ctx);
expect(again.wrote).toBe(0);
expect([...drafts.values()][0]?.landed).toBe(true);
});
it('值真变了才落笔,那时 un-land 是对的 —— 新的一笔要等它自己的门', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(RECIPE, ctx);
[...drafts.values()][0]!.landed = true;
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] },
ctx,
);
expect([...drafts.values()][0]?.landed).toBe(false);
expect([...drafts.values()][0]?.patch['role']).toBe('candidate');
});
/*
* **合并语义下才有意义的一条**(M4):人在件上填的格子不在配方的 `fields` 里,
* 配方重投一遍不该把它擦掉。替身从前是覆盖语义,这条断言在那时是假绿。
*/
it('人在件上填的那一格,配方重投不擦', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(RECIPE, ctx);
const row = [...drafts.values()][0]!;
row.patch['brand'] = '人填的';
await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] },
ctx,
);
expect([...drafts.values()][0]?.patch).toEqual({ brand: '人填的', role: 'candidate' });
});
});
/**
* **「只建不改」的表碰到已在库的键:对模型是拒回,对配方是常态**(2026-08-29 终审 I2)。
*
* 那句拒回写的是「别再写它,用 drop 把骨架撤掉」—— 说给模型听的。
* 配方没有「换个做法」这回事:抛出去就是一单永久停在那儿,而人看见的是一句他办不了的话。
*/
describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', () => {
const RECIPE: AutoRecipe = {
do: [
{
kind: 'project',
into: 'sub_eval.part',
from: { rows: [{ pn: 'IN_LIB' }, { pn: 'NEW' }] },
fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' }, role: 'candidate' },
},
],
};
it('已在库的那一颗跳过、别的照写 —— 不交人', async () => {
const { runner, drafts, formal, notes, ctx } = harness({});
formal.add('sub_eval.part|W05-1|IN_LIB');
const out = await runner.run(RECIPE, ctx);
expect(out.failedAt).toBeUndefined();
expect(out.wrote).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['NEW']);
expect(notes.some((n) => n.includes('已在库、这一笔不用建'))).toBe(true);
});
it('已在库的那一颗**不会被 prune 撤掉** —— 它是这一笔要的行,只是不用建', async () => {
const { runner, drafts, formal, ctx } = harness({});
// 先铺两行草稿(此刻库里还没有 IN_LIB)
await runner.run(RECIPE, ctx);
expect(drafts.size).toBe(2);
// 数据工单确认入库:IN_LIB 进了正式表(草稿层那一行仍在,打了 landed 记号)
formal.add('sub_eval.part|W05-1|IN_LIB');
for (const d of drafts.values()) if (d.key['internal_pn'] === 'IN_LIB') d.landed = true;
const out = await runner.run({ do: [{ ...RECIPE.do[0]!, prune: true } as never] }, ctx);
expect(out.dropped).toBe(0);
expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['IN_LIB', 'NEW']);
expect([...drafts.values()].find((d) => d.key['internal_pn'] === 'IN_LIB')?.landed).toBe(true);
});
});