From 6212b34e39d88e54e7b684651afebd2f657e1e60 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 08:21:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(loop-engine):=20=E9=85=8D=E6=96=B9=20submit?= =?UTF-8?q?=20=E6=8A=9B=E9=94=99=E4=B8=8D=E5=86=8D=E8=AE=A9=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E5=B4=A9,=E9=87=8D=E5=81=9A=E9=97=B8=E4=B9=9F?= =?UTF-8?q?=E8=AE=A4=E9=85=8D=E6=96=B9=E7=9A=84=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处,缺一不可 —— 它们合起来是一个必然发生的无限热循环: ① 配方那一路的 `raiseAcceptance` 从前是**裸着**调的。而 `engine.advance` 不捕获、 `task-service.tick` 也不捕获(且 `addConsumption` 排在 advance 之后,一步都不记)、 `task-advancer` 只写 stderr 就 break,任务留在 `running` —— 下一个 interval 再来一遍: 不烧预算、不换状态、界面纹丝不动。:1738 模型判齐那处早就包着 try/catch, 注释白纸黑字写着理由「推进器会一遍遍重来,每遍再撞一次同一道门」。 现在同款:拒绝 + 留痕 + status running。 ② 而它**必然**抛:重做闸(2026-08-23)判「打回之后台账动过没有」只认 `kind:'action'`, 那是模型 write/drop 的痕;配方的写留痕是 `observation` + `detail.auto` + `detail.ledger`。 于是配方改了台账闸也看不见 → 抛。软化那一段靠 `refusedBefore`(模型路径 catch 写的痕), 配方路径从前没有 catch,那条痕永远不写 —— **这堵墙对配方是永远开不了的**。 3 测(拒回 running / 拒过一次再提就放行 / 配方的写解得开闸),两处各做过变异验证。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/turn-runner-acceptance.test.ts | 77 +++++++++++++++++++ .../internal/usecase/turn-runner.ts | 35 ++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) 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 c7ad246..adceb87 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 @@ -2484,3 +2484,80 @@ describe('auto 步:配方在拍首跑', () => { expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); }); }); + +/** + * **配方 submit 的三条**(2026-08-29 终审 C3)。 + * + * 从前 `raiseAcceptance` 在配方那一路是**裸着**调的:`engine.advance` 不捕获、 + * `task-service.tick` 也不捕获(且 `addConsumption` 排在 advance 之后,一步都不记)、 + * `task-advancer` 只写 stderr 就 break、任务留在 `running` —— 下一个 interval 再来一遍: + * 无限热循环,不烧预算、不换状态、界面纹丝不动。而它**必然**会抛:重做闸认的 + * 「改过台账」只看 `kind:'action'`,配方的写留痕是 `observation` + `detail.auto`。 + */ +describe('auto 步 · submit 抛错不许让回合崩(C3)', () => { + const AUTO_RELEASE: CaseModel = { + name: '一步放行', + autoComplete: false, + items: [{ id: 'T_release', name: '放行', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }], + sentries: [], + } as never; + const AUTO_RELEASE_RUNTIME: CaseRuntime = { items: [{ id: 'T_release', state: 'Available' }] } as never; + const RELEASE_STEP = { + item: 'T_release', + guide: '', + writes: [], + reads: [], + tools: [], + acceptanceChecklist: ['逐条核对'], + auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] }, + }; + const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' }; + /** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger`(auto-runner 的真实形状) */ + const AUTO_WROTE = { + kind: 'observation', + title: '差异明细:写下 2 行', + detail: { auto: 'T_release#1', ledger: 'substitution.difference_item' }, + }; + + it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => { + const h = harness({ + parsed: AUTO_RELEASE, + runtime: AUTO_RELEASE_RUNTIME, + sfeelCondition: () => false, + trail: [REDO], + 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('一个字没改'); + }); + + it('那条拒回痕就是解锁凭据 —— 下一拍再提交就放行(墙要配路,不把单子锁死)', async () => { + const h = harness({ + parsed: AUTO_RELEASE, + runtime: AUTO_RELEASE_RUNTIME, + sfeelCondition: () => false, + trail: [REDO, { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' }], + steps: [RELEASE_STEP], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']); + expect(out.status).toBe('awaiting'); + }); + + it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger 也解得开重做闸', async () => { + const h = harness({ + parsed: AUTO_RELEASE, + runtime: AUTO_RELEASE_RUNTIME, + sfeelCondition: () => false, + trail: [REDO, AUTO_WROTE], + steps: [RELEASE_STEP], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']); + expect(out.status).toBe('awaiting'); + expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false); + }); +}); 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 1267080..76fb0eb 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 @@ -1533,7 +1533,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at; if (lastRedo >= 0) { const after = window.slice(lastRedo + 1); - const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string'); + /* + * **配方写下的那一笔也算「改过台账」**(2026-08-29 终审 C3)。 + * `kind: 'action'` 只有模型的 write / drop 才写;配方的写留痕是 + * `kind: 'observation'` + `detail.auto` + `detail.ledger`(见 auto-runner 的 project 分支)。 + * 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** —— + * 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕, + * 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。 + */ + const revised = after.some( + (e) => (e.kind === 'action' || typeof e.detail?.['auto'] === 'string') && typeof e.detail?.['ledger'] === 'string', + ); const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK)); if (!revised && !refusedBefore) { throw new Error( @@ -1992,8 +2002,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; } if (auto.submit !== undefined) { - const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale); - return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + /* + * **抛了不能让回合崩**(2026-08-29 终审 C3)—— 与 :1738 模型判齐那处同一条理由, + * 那儿的注释白纸黑字写着:「推进器会一遍遍重来,每遍再撞一次同一道门」。 + * 而这一处从前是**裸着**的:`engine.advance` 不捕获、`task-service.tick` 也不捕获 + * 且 `addConsumption` 排在 advance 之后(一步都不记)、`task-advancer` 只写 stderr 就 break, + * 任务留在 `running` —— 下一个 interval 再来一遍:**无限热循环,不烧预算、不换状态、界面纹丝不动**。 + * 处置与模型路径一致:拒绝 + 留痕(那条痕同时是重做闸的解锁凭据)+ 这一拍到此为止。 + */ + try { + const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale); + return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } 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: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' }, + }); + return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' }; + } } if (auto.issuedCardId !== undefined) { /* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */