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
@@ -2561,3 +2561,55 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
});
});
/**
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
*
* 从前失败只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
* `running` 的单:卡已答完、没立监听、没有时限,再没有任何东西会叫醒它。
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来。
*/
describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
const AUTO_MODEL: CaseModel = {
name: '一步的活动',
autoComplete: false,
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
const BROKEN = {
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
};
it('发一张卡给负责人 + 立人事件监听 —— 原话摆在卡上,不再伪装成「等外部回音」', async () => {
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN] });
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
expect(h.decided).toHaveLength(0);
expect(h.issued).toHaveLength(1);
expect(h.issued[0]?.workOrder).toBe('execution');
expect(String(h.issued[0]?.card['title'])).toContain('配方办不下去');
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
expect(basis.find((b) => b.k === '这一步')?.v).toBe('S1');
expect(basis.find((b) => b.k === '办不下去的原因')?.v).toContain('nope.nope');
// 立了人事件监听,人一答就叫得醒
expect(h.listened).toEqual([{ eventType: 'human' }]);
});
it('这一单还有卡没交回时不重复发 —— 叫了人就等人(runTurn 开头那道门先把住)', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
unanswered: [{ id: 'card-9', title: '配方办不下去:「S1」第 1 笔' }],
steps: [BROKEN],
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
});
});