fix(loop-engine): 配方 submit 抛错不再让回合崩,重做闸也认配方的写

两处,缺一不可 —— 它们合起来是一个必然发生的无限热循环:

① 配方那一路的 `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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:21:58 +08:00
co-authored by Claude Opus 5
parent 3190392a1d
commit 6212b34e39
2 changed files with 109 additions and 3 deletions
@@ -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,自动收步那道门才诚实 */