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:
@@ -46,6 +46,29 @@ describe('决策卡的面孔与清单(装配层兜底)', () => {
|
|||||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
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 查不了;两样都递全了不必查', () => {
|
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
|
||||||
expect(needsStepLookup({})).toBe(false);
|
expect(needsStepLookup({})).toBe(false);
|
||||||
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ describe('件上保存之后', () => {
|
|||||||
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
||||||
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
||||||
*/
|
*/
|
||||||
it('没有卡在等人(验收态就是这一种):件照旧重推,**不叫醒**', async () => {
|
/*
|
||||||
|
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
|
||||||
|
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
|
||||||
|
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
|
||||||
|
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
|
||||||
|
*/
|
||||||
|
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
|
||||||
const { calls, deps } = spy([]);
|
const { calls, deps } = spy([]);
|
||||||
await afterSave(deps, TASK, 3);
|
await afterSave(deps, TASK, 3);
|
||||||
expect(calls).toEqual(['repush', 'pending']);
|
expect(calls).toEqual(['repush', 'pending']);
|
||||||
|
|||||||
@@ -233,6 +233,14 @@ export function createOnlyViolation(
|
|||||||
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
|
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
|
||||||
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来
|
* 「确认通过」当场可按 —— 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(
|
export function decisionCardFace(
|
||||||
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
|
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
|
||||||
@@ -240,7 +248,11 @@ export function decisionCardFace(
|
|||||||
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
|
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
|
||||||
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
|
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
|
||||||
const checklist =
|
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 };
|
return { reviewFace, checklist };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
* **`carded` 只跳过这一笔,不收拍**(2026-08-29 终审 I3)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ function harness(
|
|||||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||||
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
||||||
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||||
|
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
|
||||||
|
issueRejects?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Harness {
|
): Harness {
|
||||||
const issued: Harness['issued'] = [];
|
const issued: Harness['issued'] = [];
|
||||||
@@ -179,6 +181,7 @@ function harness(
|
|||||||
} as never,
|
} as never,
|
||||||
cards: {
|
cards: {
|
||||||
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
|
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
|
||||||
|
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
|
||||||
issued.push({ workOrder: input.workOrder, card: input.card });
|
issued.push({ workOrder: input.workOrder, card: input.card });
|
||||||
return { id: `card-${issued.length}` };
|
return { id: `card-${issued.length}` };
|
||||||
},
|
},
|
||||||
@@ -2512,11 +2515,20 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
|||||||
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
|
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
|
||||||
};
|
};
|
||||||
const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' };
|
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 = {
|
const AUTO_WROTE = {
|
||||||
kind: 'observation',
|
kind: 'observation',
|
||||||
title: '差异明细:写下 2 行',
|
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 () => {
|
it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => {
|
||||||
@@ -2547,7 +2559,7 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
|||||||
expect(out.status).toBe('awaiting');
|
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({
|
const h = harness({
|
||||||
parsed: AUTO_RELEASE,
|
parsed: AUTO_RELEASE,
|
||||||
runtime: AUTO_RELEASE_RUNTIME,
|
runtime: AUTO_RELEASE_RUNTIME,
|
||||||
@@ -2560,6 +2572,26 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
|||||||
expect(out.status).toBe('awaiting');
|
expect(out.status).toBe('awaiting');
|
||||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
|
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);
|
await h.runner.runTurn(TASK);
|
||||||
expect(h.issued).toHaveLength(0);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
||||||
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
||||||
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
||||||
|
*
|
||||||
|
* **`detail.changed`**(2026-08-29 复审 M1/M2 二审):这一笔只要有 `localExisting`
|
||||||
|
* 或 `localUnkeyed` 就会留痕(M1/M2 立的规矩——「一行没写也要出声」),而这两种局面
|
||||||
|
* **台账其实一个字没动**。重做闸(`raiseAcceptance` 的 `revised`)认的是
|
||||||
|
* 「配方写下的这一笔」,不点名区分就会把「全表已在库、这一笔不用建」这类空转
|
||||||
|
* 也当成「改过了」放行 —— 人打回重做,配方原样重跑一遍、一个字没改,闸却开了。
|
||||||
|
* 只有真写了或真撤了才算改过世界。
|
||||||
*/
|
*/
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId: context.taskId,
|
taskId: context.taskId,
|
||||||
@@ -209,7 +216,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
||||||
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
||||||
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
|
(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 };
|
return { wrote: localWrote, dropped: localDropped };
|
||||||
|
|||||||
@@ -1542,10 +1542,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
||||||
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
||||||
* 配方路径从前没有 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(
|
const revised = after.some((e) => {
|
||||||
(e) => (e.kind === 'action' || typeof e.detail?.['auto'] === 'string') && typeof e.detail?.['ledger'] === 'string',
|
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));
|
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(
|
||||||
@@ -2026,9 +2037,18 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
||||||
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
* 走到这儿时这一单**没有**未答的卡(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 stuck = await deps.tasks.of(taskId);
|
||||||
const failed = auto.failedAt;
|
const failed = auto.failedAt;
|
||||||
|
try {
|
||||||
const card = await deps.cards.issue({
|
const card = await deps.cards.issue({
|
||||||
taskId,
|
taskId,
|
||||||
ownerId: stuck.ownerId,
|
ownerId: stuck.ownerId,
|
||||||
@@ -2054,6 +2074,22 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
|||||||
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||||
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
* 停下来的是系统按配方走不下去,不是 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 {
|
return {
|
||||||
runtime: stateRuntime,
|
runtime: stateRuntime,
|
||||||
enabled: stateEnabled,
|
enabled: stateEnabled,
|
||||||
|
|||||||
@@ -1349,3 +1349,33 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
|||||||
写成 `kind: 'result'` 或顺手加 `internal: true`,它会**静默地**从工作日志上消失 ——
|
写成 `kind: 'result'` 或顺手加 `internal: true`,它会**静默地**从工作日志上消失 ——
|
||||||
没有测试会红、装包也不会拒,人只会发现「这一步好像没留痕」。目前靠代码注释提醒
|
没有测试会红、装包也不会拒,人只会发现「这一步好像没留痕」。目前靠代码注释提醒
|
||||||
(`turn-runner.ts` R10 附近),没有静态检查兜底。
|
(`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 重写之前**。
|
||||||
|
|||||||
Reference in New Issue
Block a user