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
@@ -2484,3 +2484,80 @@ describe('auto 步:配方在拍首跑', () => {
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); 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);
});
});
@@ -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; for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
if (lastRedo >= 0) { if (lastRedo >= 0) {
const after = window.slice(lastRedo + 1); 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)); const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
if (!revised && !refusedBefore) { if (!revised && !refusedBefore) {
throw new Error( throw new Error(
@@ -1992,8 +2002,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
} }
if (auto.submit !== undefined) { if (auto.submit !== undefined) {
/*
* **抛了不能让回合崩**(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); const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; 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) { if (auto.issuedCardId !== undefined) {
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */ /* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */