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
@@ -1542,10 +1542,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
* 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。
*
* **配方那一路要求真写了或真撤了**(2026-08-29 复审 M1/M2 二审):`project` 动作里
* `localExisting`(已在库、这一笔不用建)与 `localUnkeyed`(主键算不出来没写)也会
* 留痕(M1/M2 立的「一行没写也要出声」),两者台账其实一个字没动。只看
* `detail.auto` + `detail.ledger` 这两个键在的话,一颗料 skipExisting、或一行主键
* 算不出来,这条痕就照样解锁 —— 人打回重做,配方原样重跑,闸却开了(正是这道闸
* 要拦的空转重提)。auto-runner 现在把「真改过」摆成 `detail.changed`,这里照它认;
* `kind:'action'` 那一支不必看这个键 —— 模型的 write/drop 一行没变就直接拒(见
* :980/:1075),能落成 `kind:'action'` 的一定是真改过的。
*/
const revised = after.some(
(e) => (e.kind === 'action' || typeof e.detail?.['auto'] === 'string') && typeof e.detail?.['ledger'] === 'string',
);
const revised = after.some((e) => {
if (typeof e.detail?.['ledger'] !== 'string') return false;
if (e.kind === 'action') return true;
return typeof e.detail?.['auto'] === 'string' && e.detail?.['changed'] === true;
});
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
if (!revised && !refusedBefore) {
throw new Error(
@@ -2026,34 +2037,59 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
*
* **这一句「由调人预算兜底」本身就是引爆点**(2026-08-29 复审 I1 二审):
* `deps.cards.issue` 走的是 `human-tool-service.assertBudget`,调人预算一旦触顶
* 就抛 `HumanCallBudgetExceededError` —— 而这一段从前是**裸着**调的,一抛就精确
* 复现 C3 那种无限热循环(`engine.advance` / `task-service.tick` 都不捕获,
* `task-advancer` 只写 stderr 就 break,任务停在 `running`、界面纹丝不动、每 2 秒重来)。
* 与 :2073(C3 那处)同款收场:抛了不能让回合崩,退回旧行为(留痕 + `awaiting`)
* 总好过热循环。
*/
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:配方那一笔归系统灰)。
*/
try {
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:配方那一笔归系统灰)。
*/
} catch (error) {
/* 兜底卡本身发不出去 —— 人要知道为什么停了,痕里把两层原因都摆出来 */
const reason = error instanceof Error ? error.message : String(error);
await deps.memory.append({
taskId,
kind: 'result',
title: `配方办不下去,连兜底卡都没能发出去:${reason}`,
detail: {
auto: current.item,
hint:
`本该交给人的那句话:「第 ${failed.index + 1} 笔 —— ${failed.message}」。` +
'发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去),' +
'也可能是活动包配方写错了(去设计台改完重跑)。',
},
});
}
return {
runtime: stateRuntime,
enabled: stateEnabled,