feat(loop-engine): auto 步的卡答过了就收步

四个前提都成立才收:声明了 auto、卡发过、这一拍没有未答的卡、配方也没再发新卡。
卡还没发过就不收 —— 那才是替人签字。

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 04:47:07 +08:00
co-authored by Claude Opus 5
parent 80e9b01f84
commit 343a281af5
3 changed files with 73 additions and 1 deletions
@@ -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');
});
});
@@ -54,6 +54,11 @@ export type AutoOutcome = {
const keyText = (key: Readonly<Record<string, string>>): 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<AutoOutcome>;
} {
@@ -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({