fix(loop-engine): auto 收步补两处 —— 真测 carded 门 + 半 auto 不抢模型的活

评审 Important:「卡还没发过就不收」那条用例其实没走到 carded 门(命中的是 Task 7
既有的发卡分支),换一条真正让 card 被 when 挡掉、只有别的笔照写的用例才让执行落进
carded 门;原用例改名留作 Task 7 那条路径的回归,逻辑不动。

R9(基于评审 Minor #1 的裁定):收步条件加一项 guide 为空 —— 半 auto(声明了非空
guide)的步,人答过卡之后那份判断仍是模型的活,不该被这段抢在模型前面收掉。

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 05:13:24 +08:00
co-authored by Claude Opus 5
parent 343a281af5
commit f59e237035
2 changed files with 88 additions and 2 deletions
@@ -2313,7 +2313,13 @@ describe('auto 步:配方在拍首跑', () => {
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
it('卡还没发过就不收 —— 那才是替人签字', async () => {
/*
* 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7
* 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回),
* 从没走到 Task 8 新加的 `carded` 门(:2019 起那一段)。名字改准,逻辑不动 ——
* 它仍是一条有效回归(证明配方在没发过卡时确实把卡发了出去),只是不再冒充「验过 carded 门」。
*/
it('还没发过卡 → 这一拍把卡发出去(Task 7 既有路径,不是 carded 门 —— 见下一条)', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
@@ -2333,4 +2339,79 @@ describe('auto 步:配方在拍首跑', () => {
expect(h.issued).toHaveLength(1);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
});
/*
* **真正把执行推到 :2019 且 carded === false 的用例**(评审 Important)。
*
* 配方两笔:`project` 无条件照写(worldTouched 不受它影响)、`card` 带 `when` 这一拍算假、
* 被跳过 —— 卡**没有**发出去,`autoRunner.run` 回的是 `issuedCardId: undefined`。
* `auto.wrote > 0` 触发一次 doneWhen 重扫,可这一步没声明 doneWhen,重扫不改变局面,
* 于是**第一次**真的落到收步判断那一段:`recipeIssuesCard(current.auto)` 因为配方里
* 结构上确实有一笔 `card`(不看这一拍它有没有被 `when` 挡住)算出 true,`carded` 却是假
* ——这道门就是靠 `carded` 才没把它错收。`carded &&` 这个条件若被误删,这条会变红。
*/
it('配方这一拍卡被 when 挡住没发、只有别的笔照写 → carded 是假,这一步不该被收', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: {
do: [
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
{
kind: 'card',
when: '恒假',
as: 'decide',
variant: 'review',
title: '结论审核',
options: [{ value: 'approve', label: '确认通过' }],
basis: [{ k: 'a', v: 'b' }],
},
],
},
},
],
});
const out = await h.runner.runTurn(TASK);
// project 照写,card 被 when 挡掉没发
expect(h.written).toHaveLength(1);
expect(h.issued).toHaveLength(0);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
});
/*
* **R9**(基于评审 Minor #1 的裁定):半 auto(声明了非空 `guide`)的步,卡答过了也不该
* 被这段收掉 —— 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」,人答完卡之后
* 那份判断(读人写的自由文本、落到具体的行上)恰恰是模型该干的活。
* 用默认的 `submit` 动作(harness 不传 `action` 时的缺省值):它只发验收卡、不碰项状态,
* 这样「S1 没被收成 Completed」这条断言测的确实是「没被这段收掉」,不是被模型自己收掉的假阳性。
*/
it('R9:半 auto(有 guide)的步卡也答过了 —— 不该被收在模型之前,那份判断仍是模型的活', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
unanswered: [],
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '把结论落进台账,再判断该不该收步',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(1);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
});
});
@@ -2021,10 +2021,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
* 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
* 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
*
* **半 auto(声明了非空 `guide`)的步不走这条近道**(R9,评审 Minor #1 逼出来的裁定):
* 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」—— 人答完卡之后那份判断
* (读人写的自由文本、落到具体的行上)恰恰是模型该干的活。收在它前面,等于把半 auto
* 的后半截整个删掉。当前没有活动踩到这个组合,但机制不该埋这个坑。
*/
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
if (noDoneWhen && carded && recipeIssuesCard(current.auto)) {
if (noDoneWhen && carded && recipeIssuesCard(current.auto) && current.guide.trim() === '') {
const active = readyToWork(parsed, stateRuntime, current.item);
const settled = transitionItem(active, current.item, 'Completed');
await deps.memory.append({