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
+21 -2
View File
@@ -1046,7 +1046,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
patch: Record<string, unknown>;
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
dryRun?: boolean;
}) {
/** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */
skipUnchanged?: boolean;
/** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */
skipExisting?: boolean;
}): Promise<{ changed: boolean; skipped?: boolean }> {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
if (target === undefined) {
@@ -1079,7 +1083,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
.map((d) => d.key);
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
if (why !== undefined) throw new Error(why);
/*
* **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是
* 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」——
* 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿,
* 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。
*/
if (why !== undefined) {
if (input.skipExisting === true) return { changed: false, skipped: true };
throw new Error(why);
}
}
/*
@@ -1155,6 +1168,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (input.dryRun === true) return { changed };
/*
* **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert
* 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。
* 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。
*/
if (input.skipUnchanged === true && !changed) return { changed };
await deps.ledger.draftWrite({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },