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
@@ -110,6 +110,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
const before = rowsAt(base.scope, `mine:${action.into}`);
const kept = new Set<string>();
let localWrote = 0;
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */
let localExisting = 0;
for (const { row, outer } of expandSource(action.from, base)) {
const computed: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.fields)) {
@@ -121,8 +123,26 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
if (decl.keys.some((column) => key[column] === '')) continue;
const patch: Record<string, unknown> = {};
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
if (changed) localWrote += 1;
/*
* 两个旗都是**配方专用**(2026-08-29 终审 I2),见 `TaskDataGateway.draftWrite` 上的注释:
* · `skipUnchanged` —— 值没变就别落笔。草稿层的 upsert 尾巴上有 `landed_at = null`,
* 而配方每一拍都把同一张表重投一遍:照写会把数据工单已确认入库的那些行**悄悄 un-land**,
* 整单放行时再写一遍正式表、再抛一遍 data 事件。
* · `skipExisting` —— 「只建不改」的表碰到已在库的键当「不用建」跳过。
* 那句拒回是写给模型看的;配方抛出去就是永久停单,而人看见一句他办不了的话。
*/
const { changed, skipped } = await deps.data.draftWrite({
taskId: context.taskId,
domain: decl.domain,
object: decl.object,
key,
patch,
skipUnchanged: true,
skipExisting: true,
});
if (skipped === true) localExisting += 1;
else if (changed) localWrote += 1;
/* 已在库的那一颗也算「这一笔要的行」—— prune 不许把它从草稿层撤掉 */
kept.add(keyText(key));
}
let localDropped = 0;
@@ -135,7 +155,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
if (gone) localDropped += 1;
}
}
if (localWrote > 0 || localDropped > 0) {
if (localWrote > 0 || localDropped > 0 || localExisting > 0) {
/*
* **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'`
* 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。
@@ -144,7 +164,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
await deps.memory.append({
taskId: context.taskId,
kind: 'observation',
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
title:
`${decl.label}:写下 ${localWrote} 行` +
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`),
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into },
});
}
@@ -157,6 +157,8 @@ const REDO_MARK = '验收被打回重做';
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
*/
const REFUSE_MARK = '打回之后台账一个字没改';
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
const AUTO_FAIL_MARK = '配方办不下去';
export function toolInputHash(input: unknown): string {
const text = JSON.stringify(input) ?? '';
@@ -1999,7 +2001,49 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
});
if (auto.failedAt !== undefined) {
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
/*
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
*
* 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
* `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
*/
const stuck = await deps.tasks.of(taskId);
const failed = auto.failedAt;
const card = await deps.cards.issue({
taskId,
ownerId: stuck.ownerId,
humanCallCap: stuck.humanCallCap,
workOrder: 'execution',
card: {
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
ask:
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
basis: [
{ k: '这一步', v: current.item },
{ k: '第几笔', v: `${failed.index + 1}` },
{ k: '办不下去的原因', v: failed.message },
],
fields: ['decision'],
},
});
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
/*
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
*/
return {
runtime: stateRuntime,
enabled: stateEnabled,
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
status: 'awaiting',
};
}
if (auto.submit !== undefined) {
/*