diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 11e9e6a..93bffe2 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -1046,7 +1046,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { patch: Record; /** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */ dryRun?: boolean; - }) { + /** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */ + skipUnchanged?: boolean; + /** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */ + skipExisting?: boolean; + }): Promise<{ changed: boolean; skipped?: boolean }> { const allowed = await taskData.writable(input.taskId); const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); if (target === undefined) { @@ -1079,7 +1083,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { .filter((d) => d.landed && d.domain === input.domain && d.object === input.object) .map((d) => d.key); const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray>, landedKeys); - if (why !== undefined) throw new Error(why); + /* + * **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是 + * 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」—— + * 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿, + * 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。 + */ + if (why !== undefined) { + if (input.skipExisting === true) return { changed: false, skipped: true }; + throw new Error(why); + } } /* @@ -1155,6 +1168,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); if (input.dryRun === true) return { changed }; + /* + * **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert + * 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。 + * 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。 + */ + if (input.skipUnchanged === true && !changed) return { changed }; await deps.ledger.draftWrite({ taskId: input.taskId, address: { domain: input.domain, object: input.object }, 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 927bfea..a2b0311 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 @@ -42,7 +42,12 @@ function asLogEntry(captured: { kind: string; title: string; detail: Record = {}, override: { analyze?: AnalyzeStub } = {}) { - const drafts = new Map; patch: Record }>(); + const drafts = new Map< + string, + { domain: string; object: string; key: Record; patch: Record; landed: boolean } + >(); + /** 「只建不改」的表里已经躺在**正式表**的键(`域.对象|键值…`)—— 生产由 `createOnlyViolation` 判 */ + const formal = new Set(); const notes: string[] = []; /** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */ const appended: Array<{ kind: string; title: string; detail: Record | undefined }> = []; @@ -60,11 +65,42 @@ function harness(seed: Record = {}, override: { analyze?: Analy { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] }, { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, ]), - draftWrite: (i: { domain: string; object: string; key: Record; patch: Record }) => { + /** + * **合并语义,不是覆盖**(2026-08-29 终审 M4):生产的 upsert 是 + * `patch = 草稿.patch || excluded.patch`(`ledger-store.ts:369`)—— + * 从前这个替身写成 `drafts.set(id, { patch })` 是**覆盖**, + * 「重投不会擦掉人填的格子」那条结论在覆盖语义下根本不成立,替身证明不了它。 + * + * 同批补上生产真有、而替身从前没有的两件事: + * · 任何一次真落笔都把 `landed` 打回 false —— 生产的 upsert 尾巴上写着 `landed_at = null`; + * · 「只建不改」的表碰到已在正式表里的键:默认拒(抛),`skipExisting` 时跳过。 + */ + draftWrite: (i: { + domain: string; + object: string; + key: Record; + patch: Record; + skipUnchanged?: boolean; + skipExisting?: boolean; + }) => { const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; - const before = JSON.stringify(drafts.get(id)?.patch ?? null); - drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch }); - return Promise.resolve({ changed: before !== JSON.stringify(i.patch) }); + if (formal.has(id)) { + if (i.skipExisting === true) return Promise.resolve({ changed: false, skipped: true }); + return Promise.reject(new Error(`${i.domain}.${i.object} 里已经有这一行 —— 这张表只许新建库里没有的记录`)); + } + const before = drafts.get(id); + const changed = + before === undefined || + Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); + if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed }); + drafts.set(id, { + domain: i.domain, + object: i.object, + key: i.key, + patch: { ...(before?.patch ?? {}), ...i.patch }, + landed: false, + }); + return Promise.resolve({ changed }); }, draftDrop: (i: { domain: string; object: string; key: Record }) => { const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`; @@ -99,7 +135,7 @@ function harness(seed: Record = {}, override: { analyze?: Analy }; const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法 - return { runner: createAutoRunner(deps as never), drafts, notes, appended, cards, ctx }; + return { runner: createAutoRunner(deps as never), drafts, formal, notes, appended, cards, ctx }; } describe('配方执行器', () => { @@ -123,6 +159,7 @@ describe('配方执行器', () => { object: 'part', key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' }, patch: { role: 'original' }, + landed: false, }); }); @@ -444,3 +481,103 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () expect(lines[0]?.who).toBe('system'); }); }); + +/** + * **幂等在「反复进入」下也得成立**(2026-08-29 终审 I2)。 + * + * 配方每一拍都把同一张表重投一遍。而草稿层的 upsert 尾巴上有 `landed_at = null` + * (「再写一笔就不再是已落地」)—— 数据工单确认入库之后再重投,那些行的「已落地」记号 + * 就被悄悄擦掉,整单放行时会被再写一遍正式表、再抛一遍 data 事件, + * 而 CLAUDE.md 对 landed 行的承诺是「整单放行不再写它、不抛事件」。 + */ +describe('幂等 · 重投不许把已落地的行 un-land(I2)', () => { + const RECIPE: AutoRecipe = { + do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'original' } }], + }; + + it('值没变就压根不落笔 —— 已落地的记号原样还在', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run(RECIPE, ctx); + // 数据工单确认入库:这一行打上「已落地」记号(生产是 `__draft.landed_at`) + const row = [...drafts.values()][0]!; + row.landed = true; + + const again = await runner.run(RECIPE, ctx); + expect(again.wrote).toBe(0); + expect([...drafts.values()][0]?.landed).toBe(true); + }); + + it('值真变了才落笔,那时 un-land 是对的 —— 新的一笔要等它自己的门', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run(RECIPE, ctx); + [...drafts.values()][0]!.landed = true; + + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] }, + ctx, + ); + expect([...drafts.values()][0]?.landed).toBe(false); + expect([...drafts.values()][0]?.patch['role']).toBe('candidate'); + }); + + /* + * **合并语义下才有意义的一条**(M4):人在件上填的格子不在配方的 `fields` 里, + * 配方重投一遍不该把它擦掉。替身从前是覆盖语义,这条断言在那时是假绿。 + */ + it('人在件上填的那一格,配方重投不擦', async () => { + const { runner, drafts, ctx } = harness({}); + await runner.run(RECIPE, ctx); + const row = [...drafts.values()][0]!; + row.patch['brand'] = '人填的'; + + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] }, + ctx, + ); + expect([...drafts.values()][0]?.patch).toEqual({ brand: '人填的', role: 'candidate' }); + }); +}); + +/** + * **「只建不改」的表碰到已在库的键:对模型是拒回,对配方是常态**(2026-08-29 终审 I2)。 + * + * 那句拒回写的是「别再写它,用 drop 把骨架撤掉」—— 说给模型听的。 + * 配方没有「换个做法」这回事:抛出去就是一单永久停在那儿,而人看见的是一句他办不了的话。 + */ +describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', () => { + const RECIPE: AutoRecipe = { + do: [ + { + kind: 'project', + into: 'sub_eval.part', + from: { rows: [{ pn: 'IN_LIB' }, { pn: 'NEW' }] }, + fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' }, role: 'candidate' }, + }, + ], + }; + + it('已在库的那一颗跳过、别的照写 —— 不交人', async () => { + const { runner, drafts, formal, notes, ctx } = harness({}); + formal.add('sub_eval.part|W05-1|IN_LIB'); + const out = await runner.run(RECIPE, ctx); + expect(out.failedAt).toBeUndefined(); + expect(out.wrote).toBe(1); + expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['NEW']); + expect(notes.some((n) => n.includes('已在库、这一笔不用建'))).toBe(true); + }); + + it('已在库的那一颗**不会被 prune 撤掉** —— 它是这一笔要的行,只是不用建', async () => { + const { runner, drafts, formal, ctx } = harness({}); + // 先铺两行草稿(此刻库里还没有 IN_LIB) + await runner.run(RECIPE, ctx); + expect(drafts.size).toBe(2); + // 数据工单确认入库:IN_LIB 进了正式表(草稿层那一行仍在,打了 landed 记号) + formal.add('sub_eval.part|W05-1|IN_LIB'); + for (const d of drafts.values()) if (d.key['internal_pn'] === 'IN_LIB') d.landed = true; + + const out = await runner.run({ do: [{ ...RECIPE.do[0]!, prune: true } as never] }, ctx); + expect(out.dropped).toBe(0); + expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['IN_LIB', 'NEW']); + expect([...drafts.values()].find((d) => d.key['internal_pn'] === 'IN_LIB')?.landed).toBe(true); + }); +}); 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 adceb87..7d3b513 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 @@ -2561,3 +2561,55 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => { expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false); }); }); + +/** + * **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。 + * + * 从前失败只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡 + * `running` 的单:卡已答完、没立监听、没有时限,再没有任何东西会叫醒它。 + * 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来。 + */ +describe('auto 步 · 配方办不下去要给人一条路(I1)', () => { + const AUTO_MODEL: CaseModel = { + name: '一步的活动', + autoComplete: false, + items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }], + sentries: [], + } as never; + const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never; + const BROKEN = { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, + }; + + it('发一张卡给负责人 + 立人事件监听 —— 原话摆在卡上,不再伪装成「等外部回音」', async () => { + const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN] }); + const out = await h.runner.runTurn(TASK); + expect(out.status).toBe('awaiting'); + expect(h.decided).toHaveLength(0); + expect(h.issued).toHaveLength(1); + expect(h.issued[0]?.workOrder).toBe('execution'); + expect(String(h.issued[0]?.card['title'])).toContain('配方办不下去'); + const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>; + expect(basis.find((b) => b.k === '这一步')?.v).toBe('S1'); + expect(basis.find((b) => b.k === '办不下去的原因')?.v).toContain('nope.nope'); + // 立了人事件监听,人一答就叫得醒 + expect(h.listened).toEqual([{ eventType: 'human' }]); + }); + + it('这一单还有卡没交回时不重复发 —— 叫了人就等人(runTurn 开头那道门先把住)', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + unanswered: [{ id: 'card-9', title: '配方办不下去:「S1」第 1 笔' }], + steps: [BROKEN], + }); + await h.runner.runTurn(TASK); + expect(h.issued).toHaveLength(0); + }); +}); 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 e2275eb..25302db 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 @@ -110,6 +110,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): { const before = rowsAt(base.scope, `mine:${action.into}`); const kept = new Set(); let localWrote = 0; + /** 「只建不改」的表里已经在库、这一笔不用建的行数 */ + let localExisting = 0; for (const { row, outer } of expandSource(action.from, base)) { const computed: Record = {}; for (const [name, expr] of Object.entries(action.fields)) { @@ -121,8 +123,26 @@ export function createAutoRunner(deps: AutoRunnerDeps): { if (decl.keys.some((column) => key[column] === '')) continue; const patch: Record = {}; for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value; - const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch }); - if (changed) localWrote += 1; + /* + * 两个旗都是**配方专用**(2026-08-29 终审 I2),见 `TaskDataGateway.draftWrite` 上的注释: + * · `skipUnchanged` —— 值没变就别落笔。草稿层的 upsert 尾巴上有 `landed_at = null`, + * 而配方每一拍都把同一张表重投一遍:照写会把数据工单已确认入库的那些行**悄悄 un-land**, + * 整单放行时再写一遍正式表、再抛一遍 data 事件。 + * · `skipExisting` —— 「只建不改」的表碰到已在库的键当「不用建」跳过。 + * 那句拒回是写给模型看的;配方抛出去就是永久停单,而人看见一句他办不了的话。 + */ + const { changed, skipped } = await deps.data.draftWrite({ + taskId: context.taskId, + domain: decl.domain, + object: decl.object, + key, + patch, + skipUnchanged: true, + skipExisting: true, + }); + if (skipped === true) localExisting += 1; + else if (changed) localWrote += 1; + /* 已在库的那一颗也算「这一笔要的行」—— prune 不许把它从草稿层撤掉 */ kept.add(keyText(key)); } let localDropped = 0; @@ -135,7 +155,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): { if (gone) localDropped += 1; } } - if (localWrote > 0 || localDropped > 0) { + if (localWrote > 0 || localDropped > 0 || localExisting > 0) { /* * **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'` * 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。 @@ -144,7 +164,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): { await deps.memory.append({ taskId: context.taskId, kind: 'observation', - title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`, + title: + `${decl.label}:写下 ${localWrote} 行` + + (localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) + + (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`), detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into }, }); } 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 76fb0eb..0a92435 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 @@ -157,6 +157,8 @@ const REDO_MARK = '验收被打回重做'; * (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。 */ const REFUSE_MARK = '打回之后台账一个字没改'; +/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */ +const AUTO_FAIL_MARK = '配方办不下去'; export function toolInputHash(input: unknown): string { const text = JSON.stringify(input) ?? ''; @@ -1999,7 +2001,49 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true, }); if (auto.failedAt !== undefined) { - return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + /* + * **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。 + * + * 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡 + * `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。 + * 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。 + * 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。 + * 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了), + * 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。 + */ + 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:配方那一笔归系统灰)。 + */ + return { + runtime: stateRuntime, + enabled: stateEnabled, + navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] }, + status: 'awaiting', + }; } if (auto.submit !== undefined) { /* diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index c4f518b..57eea9b 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -59,7 +59,21 @@ export type TaskDataGateway = { object: string; key: Record; patch: Record; - }): Promise<{ changed: boolean }>; + /** + * **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍, + * 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。 + * 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**, + * 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。 + * 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。 + */ + skipUnchanged?: boolean | undefined; + /** + * **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。 + * 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义, + * 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。 + */ + skipExisting?: boolean | undefined; + }): Promise<{ changed: boolean; skipped?: boolean }>; /** * **撤掉本单草稿层的一行** —— 写错了得有退路。 *