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 6e5550b..fe39e2f 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 @@ -2290,4 +2290,47 @@ describe('auto 步:配方在拍首跑', () => { expect(h.written).toHaveLength(1); expect(h.decided).toHaveLength(1); }); + + it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never, + unanswered: [], + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued).toHaveLength(0); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); + }); + + it('卡还没发过就不收 —— 那才是替人签字', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.issued).toHaveLength(1); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); + }); }); 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 a0f4db5..ad6c187 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 @@ -54,6 +54,11 @@ export type AutoOutcome = { const keyText = (key: Readonly>): string => Object.values(key).join('|'); +/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */ +export function recipeIssuesCard(recipe: AutoRecipe): boolean { + return recipe.do.some((action) => action.kind === 'card'); +} + export function createAutoRunner(deps: AutoRunnerDeps): { run(recipe: AutoRecipe, ctx: AutoContext): Promise; } { 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 5cc6190..4374f2f 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 @@ -25,7 +25,7 @@ import type { import { parseCmmn } from '../domain/cmmn-parse.ts'; import type { CaseModel } from '../domain/cmmn-parse.ts'; import type { CaseRuntime } from '../domain/lifecycle.ts'; -import { createAutoRunner } from './auto-runner.ts'; +import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts'; import { achieveMilestones, autoCompleteReady, @@ -2016,6 +2016,30 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'converged' }, status: 'running' }; } } + /* + * **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立: + * 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、 + * 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** —— + * 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。 + */ + const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === ''; + const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true; + if (noDoneWhen && carded && recipeIssuesCard(current.auto)) { + const active = readyToWork(parsed, stateRuntime, current.item); + const settled = transitionItem(active, current.item, 'Completed'); + await deps.memory.append({ + taskId, + kind: 'engine-transition', + /* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */ + title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`, + detail: { internal: true, item: current.item, auto: current.item }, + }); + await deps.state.save(taskId, model, parsed, settled); + /* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged, + 见上面 2014 行那条注释)—— 这一拍到此为止、下一拍重新导航,同一种写法 */ + return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' }; + } + if (current.guide.trim() === '') { /* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人 */ await deps.memory.append({