Archived
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:
@@ -198,6 +198,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
||||
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
||||
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
||||
*
|
||||
* **`detail.changed`**(2026-08-29 复审 M1/M2 二审):这一笔只要有 `localExisting`
|
||||
* 或 `localUnkeyed` 就会留痕(M1/M2 立的规矩——「一行没写也要出声」),而这两种局面
|
||||
* **台账其实一个字没动**。重做闸(`raiseAcceptance` 的 `revised`)认的是
|
||||
* 「配方写下的这一笔」,不点名区分就会把「全表已在库、这一笔不用建」这类空转
|
||||
* 也当成「改过了」放行 —— 人打回重做,配方原样重跑一遍、一个字没改,闸却开了。
|
||||
* 只有真写了或真撤了才算改过世界。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
@@ -209,7 +216,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
||||
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
||||
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true },
|
||||
detail: {
|
||||
auto: `${context.stepId}#${index + 1}`,
|
||||
ledger: action.into,
|
||||
internal: true,
|
||||
changed: localWrote > 0 || localDropped > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { wrote: localWrote, dropped: localDropped };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user