From c3fed573eee8c16e47b64c3221a93b5dd69bcb58 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 09:21:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(loop-engine):=20=E6=94=B6=E5=B0=BE=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E4=BA=8C=E5=AE=A1=20=E2=80=94=E2=80=94=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=8D=A1=E8=87=AA=E5=B7=B1=E4=BC=9A=E6=8A=9B=20/=20?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E5=85=9C=E5=BA=95=E4=B8=8D=E7=9C=8B=E9=9D=A2?= =?UTF-8?q?=E5=AD=94=20/=20=E7=A9=BA=E8=BD=AC=E5=86=99=E8=A7=A3=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审逐条核实上一波(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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../app/__tests__/decision-card-face.test.ts | 23 +++++ apps/api/src/app/__tests__/save-wake.test.ts | 8 +- apps/api/src/app/wiring/engines.ts | 14 ++- .../loop-engine/__tests__/auto-runner.test.ts | 40 ++++++++ .../__tests__/turn-runner-acceptance.test.ts | 65 ++++++++++++- .../internal/usecase/auto-runner.ts | 14 ++- .../internal/usecase/turn-runner.ts | 92 +++++++++++++------ docs/debt.md | 30 ++++++ 8 files changed, 252 insertions(+), 34 deletions(-) diff --git a/apps/api/src/app/__tests__/decision-card-face.test.ts b/apps/api/src/app/__tests__/decision-card-face.test.ts index 07f2734..9f55fb5 100644 --- a/apps/api/src/app/__tests__/decision-card-face.test.ts +++ b/apps/api/src/app/__tests__/decision-card-face.test.ts @@ -46,6 +46,29 @@ describe('决策卡的面孔与清单(装配层兜底)', () => { expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist); }); + /** + * **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。 + * + * `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局 + * `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在 + * **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 —— + * 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求 + * `checked ⊇ checklist`:卡按不下去。 + */ + const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] }; + + it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => { + const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST); + expect(face.reviewFace).toBe(false); + expect(face.checklist).toBeUndefined(); + }); + + it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => { + const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST); + expect(face.reviewFace).toBe(true); + expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist); + }); + it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => { expect(needsStepLookup({})).toBe(false); expect(needsStepLookup({ itemId: 'T_release' })).toBe(true); diff --git a/apps/api/src/app/__tests__/save-wake.test.ts b/apps/api/src/app/__tests__/save-wake.test.ts index fcb23e5..bf91540 100644 --- a/apps/api/src/app/__tests__/save-wake.test.ts +++ b/apps/api/src/app/__tests__/save-wake.test.ts @@ -36,7 +36,13 @@ describe('件上保存之后', () => { * 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human * (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。 */ - it('没有卡在等人(验收态就是这一种):件照旧重推,**不叫醒**', async () => { + /* + * **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审 + * A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards` + * (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的 + * 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。 + */ + it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => { const { calls, deps } = spy([]); await afterSave(deps, TASK, 3); expect(calls).toEqual(['repush', 'pending']); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 11883cc..5d97c87 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -233,6 +233,14 @@ export function createOnlyViolation( * **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**, * 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来 * (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。 + * + * **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审):`isReleaseStep` 那条口径决定了 + * 一步可以只声明 `acceptanceChecklist`(给终局那张 `AcceptForm` 用)而不带 `cardVariant:'review'` + * (T_release 本身就是这种形状)。配方若在**这一步**发一张普通的 `as:'decide'` 卡(既没传 + * `variant`,`step.cardVariant` 也不是 `review`),从前这里照样把 `acceptanceChecklist` 兜上去 —— + * 前端只在 `variant === 'review'` 时才渲染清单(`chat-bubbles.tsx`),人看不到勾可打, + * 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时** + * 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。 */ export function decisionCardFace( input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray | undefined }, @@ -240,7 +248,11 @@ export function decisionCardFace( ): { reviewFace: boolean; checklist: ReadonlyArray | undefined } { const reviewFace = input.variant === 'review' || step?.cardVariant === 'review'; const checklist = - input.checklist !== undefined && input.checklist.length > 0 ? input.checklist : step?.acceptanceChecklist; + input.checklist !== undefined && input.checklist.length > 0 + ? input.checklist + : reviewFace + ? step?.acceptanceChecklist + : undefined; return { reviewFace, checklist }; } diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index 167398e..fc36ba3 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -621,6 +621,46 @@ describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', }); }); +/** + * **`detail.changed` 只在真写了/真撤了时为真**(2026-08-29 复审 M1/M2 二审)。 + * + * M1/M2 让「一行没写」也出声(`localExisting`/`localUnkeyed`),而**台账其实一个字没动**—— + * 重做闸(`turn-runner.raiseAcceptance` 的 `revised`)靠这个标记分清「配方真改过台账」 + * 与「配方跑过一遍但什么也没落」,两者都会留痕,只有前者该解锁闸。 + */ +describe('project 留痕:changed 只在真写了/真撤了时为真(M1/M2 二审)', () => { + it('真写了一行:changed:true', async () => { + const { runner, appended, ctx } = harness({}); + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A' } }] }, + ctx, + ); + const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); + expect(note?.detail?.['changed']).toBe(true); + }); + + it('全在库、这一笔不用建(skipExisting):changed:false —— 台账没动', async () => { + const { runner, formal, appended, ctx } = harness({}); + formal.add('sub_eval.part|W05-1|IN_LIB'); + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'IN_LIB' } }] }, + ctx, + ); + const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); + expect(note?.detail?.['changed']).toBe(false); + }); + + it('主键算不出来、一行都没写:changed:false —— 台账没动', async () => { + const { runner, appended, ctx } = harness({}); + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: '' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, + ctx, + ); + const note = appended.find((a) => a.detail?.['ledger'] === 'sub_eval.part'); + expect(note?.detail?.['changed']).toBe(false); + }); +}); + /** * **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。 * diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index 73927ee..f53af43 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -99,6 +99,8 @@ function harness( declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record }>; /** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */ 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 }) => { + 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'); + }); }); /** diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index ce3a11c..9855b0e 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -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 }; diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index 830dfe6..8157cc5 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -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, diff --git a/docs/debt.md b/docs/debt.md index dc12f50..6352618 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -1349,3 +1349,33 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人 写成 `kind: 'result'` 或顺手加 `internal: true`,它会**静默地**从工作日志上消失 —— 没有测试会红、装包也不会拒,人只会发现「这一步好像没留痕」。目前靠代码注释提醒 (`turn-runner.ts` R10 附近),没有静态检查兜底。 + +## 收尾复审二审(2026-08-29,B 项记账) + +- **「保存后唤醒的收窄」几乎不生效**:`afterSave`(`save-wake.ts`)的 F1 门槛是 + `pendingCards` 为空才不叫醒(见 `apps/api/src/app/__tests__/save-wake.test.ts` 那条改过的 + 用例标题)——而 `pendingCards`(`human-tool-service.ts:374`)把**未办结工单**也算在内, + 人能在件上按保存的时刻(工单开着让他填)几乎恒有一张这样的工单还没结。也就是说 + 「没有卡在等人」这个分支在人真正会走到的场景里几乎从不成立,`wake` 照发、一步预算照烧 —— + F1 那道门在设计上是对的,只是它保护到的场景比想象的窄得多。真要治得重新定义 + 「什么时候算需要唤醒」(比如分清「这个工单是不是这次保存所在的那一张」),是新设计, + 不在本分支拍。 +- **模型路径的写同样会 un-land,只补了配方那一半**(复审判**中等偏高**):`DraftStore.put` + 尾部 `landed_at = null` 是无差别的 —— 同一行落草稿层一次,不论落笔的是配方(`project` + 动作)还是模型自己的 `write` 行动。本轮把配方那一侧堵上了(`skipUnchanged: true` + + `detail.changed` 只在真写了/真撤了时为真,见 `auto-runner.ts` 的 `project` 分支与 + `turn-runner.ts` 的 `revised` 判据),**模型那一侧完全没动**——模型的 `write` 行动没有 + `skipUnchanged` 这层,值没变时 `draftWrite` 照样把 `landed_at` 打回 null。 + 而 W05 的数据工单**今天**就在跑「确认即入库」(2026-08-27 的 `landed` 记号机制), + 模型只要对已入库的同一行同值重写一次(比如没读现值、把台账里已有的值原样再报一遍), + landed 记号就没了 —— 整单放行会再写一遍正式表、再抛一遍 data 事件,而 CLAUDE.md 对 + landed 行的承诺是「不再写它、不抛事件」。**同一个洞只补了还没人走的那半边** —— + 配方(`auto`)目前一个活动包都没在用,模型路径每天都在跑。**判中等偏高**(复审原话): + 不在本分支动,原因两条 —— ①这是**共享面**(`turn-runner.ts` 的 `write` 行动、 + `engines.ts:1055` 的 `draftWrite`),改法要么给模型路径也接上 `skipUnchanged` + (得先核实它是否会悄悄影响 :980 那道「一行都没变的写不是一个行动」拒回门 —— + 逐行看目前是不会,`changed` 在 `skipUnchanged` 判断之前就已经按值算好了,但没有测试钉死 + 这条不变式,改了要重新证明一遍并补测)、要么另开一条更窄的路径,两种都要走全量回归 + + 三套 e2e;②本轮改的是配方那一条新路,`git diff --stat b995148...HEAD -- activities/` + 为空这条硬约束意味着不能顺手把模型路径也捎带上、再靠一次浏览器走查收尾 —— + 排查与验收的工作量不是这一条闭合修复能捎带的。**应当独立起一条、排在 W05 重写之前**。