fix(loop-engine): 收尾复审二审 —— 兜底卡自己会抛 / 清单兜底不看面孔 / 空转写解闸

复审逐条核实上一波(C2/C3/M1/M2)修法本身引入的三处破坏,全部属实并修死:

- I1 二审:配方办不下去时发的那张兜底卡(deps.cards.issue)从前裸调,调人预算
  触顶抛 HumanCallBudgetExceededError 会精确复现 C3 那种无限热循环(推进器每
  2 秒重来、不烧预算、不换状态、界面纹丝不动)。包进 try/catch,失败留痕、
  状态退回 awaiting,不让它抛出去。配了变异验证:拿掉 try/catch 后新用例
  真的变红(runTurn 抛出预算触顶错误),还原后转绿。
- C2 二审:decisionCardFace 的 checklist 兜底从前不看 reviewFace —— 一步只声明
  acceptanceChecklist 而 cardVariant 不是 review(T_release 那种形状)时,配方发
  的普通 decide 卡也会被兜上清单,前端不渲染勾选区、回传门却要求 checked ⊇
  checklist,卡按不下去。改成只在 reviewFace 为真时才退回剧本声明的清单。
- M1/M2 二审:project 动作里 localExisting/localUnkeyed 触发的观察痕与真写
  (localWrote/localDropped)共用 detail.auto + detail.ledger 这两个键,而
  raiseAcceptance 的 revised 判据只认这两个键在不在 —— 一颗料 skipExisting、
  或一行主键算不出来,这条"什么都没动"的痕照样能解开重做闸,人打回重做、
  配方原样重跑一遍,闸却开了。auto-runner 补 detail.changed(真写了/真撤了
  才为真),revised 判据对配方那一路改认这个标记。

另两笔记账:
- save-wake.test.ts 的用例标题写着"验收态就是这一种(没有卡在等人)"是错的,
  pendingCards 把未办结工单也算在内,验收态恰恰有一张开着的验收工单——只改
  标题措辞,不动断言与实现。
- docs/debt.md 补两笔:保存后唤醒的收窄几乎不生效(pendingCards 口径过宽);
  模型路径的写同样会 un-land、只补了配方那一半(独立起一条,排在 W05 重写前)。

pnpm gates 退出码 0(2410 passed / 23 skipped);
git diff --stat b995148...HEAD -- activities/ 为空,九个活动包一字未动。

报告:.superpowers/sdd/2026-08-29-deterministic-steps-auto-recipe/closing-fix-report.md

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 09:21:02 +08:00
co-authored by Claude Opus 5
parent c60c882f06
commit c3fed573ee
8 changed files with 252 additions and 34 deletions
@@ -99,6 +99,8 @@ function harness(
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>;
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
issueRejects?: string;
} = {},
): Harness {
const issued: Harness['issued'] = [];
@@ -179,6 +181,7 @@ function harness(
} as never,
cards: {
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
issued.push({ workOrder: input.workOrder, card: input.card });
return { id: `card-${issued.length}` };
},
@@ -2512,11 +2515,20 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
};
const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' };
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger`(auto-runner 的真实形状) */
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger` + `detail.changed:true`(auto-runner 的真实形状) */
const AUTO_WROTE = {
kind: 'observation',
title: '差异明细:写下 2 行',
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item' },
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: true },
};
/**
* **空转的那一笔**(2026-08-29 复审 M1/M2 二审):`localExisting`/`localUnkeyed` 也会留痕
* (M1/M2 的「一行没写也要出声」),而台账其实一个字没动 —— `detail.changed` 是 `false`。
*/
const AUTO_NOOP = {
kind: 'observation',
title: '差异明细:写下 0 行,2 行已在库、这一笔不用建',
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: false },
};
it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => {
@@ -2547,7 +2559,7 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
expect(out.status).toBe('awaiting');
});
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger 也解得开重做闸', async () => {
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger + detail.changed:true 也解得开重做闸', async () => {
const h = harness({
parsed: AUTO_RELEASE,
runtime: AUTO_RELEASE_RUNTIME,
@@ -2560,6 +2572,26 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
expect(out.status).toBe('awaiting');
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
});
/**
* **「一行没写」也能解开重做闸**(2026-08-29 复审 M1/M2 二审,复审跟到底的那条):
* 人点了「重做」,配方重跑、一个字没改(全 skipExisting / 主键算不出来),只要留了
* M1/M2 那条「出声」的痕,从前的 `revised` 判据一样为真 —— 闸放行了原样重提。
*/
it('空转的那一笔(全在库/主键算不出来)解不开重做闸 —— 拒回原样再来一遍', async () => {
const h = harness({
parsed: AUTO_RELEASE,
runtime: AUTO_RELEASE_RUNTIME,
sfeelCondition: () => false,
trail: [REDO, AUTO_NOOP],
steps: [RELEASE_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('running');
expect(h.issued).toHaveLength(0);
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
expect(refused?.title).toContain('一个字没改');
});
});
/**
@@ -2612,6 +2644,33 @@ describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
});
/**
* **兜底卡自己也会抛 —— 精确复现 C3 那种无限热循环**(2026-08-29 复审 I1 二审)。
*
* `deps.cards.issue` 经 `human-tool-service.assertBudget`,调人预算触顶时抛
* `HumanCallBudgetExceededError`。这一句从前**裸着**调:抛出去 `runTurn` 崩、
* `engine.advance` / `task-service.tick` 都不捕获、`task-advancer` 只写 stderr 就
* break,任务停在 `running`,下一个 2 秒 interval 再来一遍 —— 不烧预算、不换状态、
* 界面纹丝不动。这条用例断言 `runTurn` **不抛**、状态落 `awaiting`、留了一条痕。
*/
it('连兜底卡都发不出去(如调人预算触顶):不抛、状态落 awaiting、留痕', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [BROKEN],
issueRejects: '调人预算已经触顶(3/3)',
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
expect(h.issued).toHaveLength(0);
expect(h.listened).toHaveLength(0);
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
expect(note).toBeDefined();
expect(note?.title).toContain('调人预算已经触顶(3/3)');
expect(String(note?.detail?.['hint'])).toContain('nope.nope');
});
});
/**