feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型

liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled,
不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。
有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。
老活动一个字不用改:剧本里没有 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:28:18 +08:00
co-authored by Claude Opus 5
parent e9da8d25f5
commit fbd5867206
5 changed files with 281 additions and 4 deletions
@@ -76,6 +76,11 @@
去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。 去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内, - **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。 那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
- **auto 步不问模型**(2026-08-29,`turn-runner` 拍首接线 `createAutoRunner`):`PlanStep.auto` 存在时,
拍首先跑配方;发了卡 / submit 就收拍(零 token)。有 `guide` 的照常问模型(半 auto);
两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。
- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。
- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体, - **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西 也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete), 发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { liveStepOf } from '../internal/domain/live-step.ts';
const steps = [
{ item: 'S1', guide: '' },
{ item: 'S2', guide: '' },
{ item: 'T_release', guide: '' },
];
describe('liveStepOf:这一单此刻在办哪一步', () => {
it('在场的第一个还没收掉的项', () => {
expect(liveStepOf(steps, new Set(), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S1');
});
it('**收掉的项要跳过** —— 带 repetitionRule 的项收了之后仍是 Enabled,不跳就整单钉在第一步', () => {
expect(liveStepOf(steps, new Set(['S1']), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S2');
});
it('在场的都收完了 → undefined', () => {
expect(liveStepOf(steps, new Set(['S1', 'S2']), [{ id: 'S1' }, { id: 'S2' }])).toBeUndefined();
});
it('在场的项没有剧本 → undefined(没写剧本的活动照旧跑)', () => {
expect(liveStepOf(steps, new Set(), [{ id: 'X' }])).toBeUndefined();
});
it('次序按 onStage 给的次序,不按 steps 的次序', () => {
expect(liveStepOf(steps, new Set(), [{ id: 'S2' }, { id: 'S1' }])?.item).toBe('S2');
});
});
@@ -2152,3 +2152,142 @@ describe('终局放行那一步:human 被拒并指向 submit', () => {
expect(h.issued.length).toBe(1); expect(h.issued.length).toBe(1);
}); });
}); });
describe('auto 步:配方在拍首跑', () => {
/** 一步的活动:那一步声明 auto */
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;
it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(out.status).toBe('awaiting');
expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']);
});
it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0,
viewFn: (written) => ({ quote_intake_record_rows: written.length }),
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
doneWhen: 'quote_intake_record_rows > 0',
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
unanswered: [{ id: 'card-9', title: '补数据' }],
sfeelCondition: () => false,
viewFn: (written) => ({ n: written.length }),
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: {
do: [
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
],
},
},
],
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(h.issued).toHaveLength(0);
});
it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(out.status).toBe('awaiting');
expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true);
});
it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
sfeelCondition: () => false,
viewFn: (written) => ({ n: written.length }),
steps: [
{
item: 'i1',
guide: '把结论写实',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] },
},
],
/*
* brief 原文这里写的是 `{ kind: 'idle', rationale: '看过了' }` ——
* `AgentAction` 判别联合里没有 `'idle'` 这一档(全仓 grep 零命中),
* `applyAction` 的 switch 又没有 default 分支,不认得的 kind 会让它
* 隐式 return undefined,下一行 `applied.runtime` 当场抛 TypeError。
* 这条用例要测的是「配方写完之后模型仍被正常问了一次」,与模型具体
* 回了哪个动作无关 —— 换成场上已有测试反复验证过的 `finish`(收掉
* i1,i1 没声明 manualActivation / doneWhen,收得干净)。
*/
action: { kind: 'finish', itemId: 'i1', rationale: '看过了' },
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(h.decided).toHaveLength(1);
});
});
@@ -0,0 +1,19 @@
/**
* **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。
*
* 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**,
* 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了,
* 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。
*/
export function liveStepOf<S extends { readonly item: string }>(
steps: readonly S[],
settledIds: ReadonlySet<string>,
onStage: readonly { readonly id: string }[],
): S | undefined {
for (const entry of onStage) {
if (settledIds.has(entry.id)) continue;
const step = steps.find((s) => s.item === entry.id);
if (step !== undefined) return step;
}
return undefined;
}
@@ -25,6 +25,7 @@ import type {
import { parseCmmn } from '../domain/cmmn-parse.ts'; import { parseCmmn } from '../domain/cmmn-parse.ts';
import type { CaseModel } from '../domain/cmmn-parse.ts'; import type { CaseModel } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts'; import type { CaseRuntime } from '../domain/lifecycle.ts';
import { createAutoRunner } from './auto-runner.ts';
import { import {
achieveMilestones, achieveMilestones,
autoCompleteReady, autoCompleteReady,
@@ -39,6 +40,7 @@ import {
transitionItem, transitionItem,
} from '../domain/lifecycle.ts'; } from '../domain/lifecycle.ts';
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts'; import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
import { liveStepOf } from '../domain/live-step.ts';
import { phaseProgress } from '../domain/phase.ts'; import { phaseProgress } from '../domain/phase.ts';
export type TurnRunnerDeps = { export type TurnRunnerDeps = {
@@ -426,6 +428,14 @@ const STALL_LIMIT = 5;
const STALL_WINDOW = 60; const STALL_WINDOW = 60;
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const autoRunner = createAutoRunner({
data: deps.data,
cards: deps.cards,
memory: deps.memory,
analyze: deps.analyze,
sfeel: deps.sfeel,
});
async function loaded(taskId: TaskId) { async function loaded(taskId: TaskId) {
const found = await deps.state.load(taskId); const found = await deps.state.load(taskId);
if (found === undefined) throw new Error(`loop 未装载:${taskId}`); if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
@@ -1683,6 +1693,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
*/ */
const waiting = await deps.cards.unanswered(taskId); const waiting = await deps.cards.unanswered(taskId);
if (waiting.length > 0) { if (waiting.length > 0) {
/*
* **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。
* 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」);
* 但不发新卡、不 submit、不调 AI —— 叫了人就等人。
*/
/* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */
const quietStep = liveStepOf(
model.steps ?? [],
new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)),
[...enabled.inProgress, ...enabled.entries],
);
if (quietStep?.auto !== undefined) {
await autoRunner.run(quietStep.auto, {
taskId,
stepId: quietStep.item,
ownerId: (await deps.tasks.of(taskId)).ownerId,
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
quiet: true,
carded: true,
});
}
await deps.memory.append({ await deps.memory.append({
taskId, taskId,
kind: 'observation', kind: 'observation',
@@ -1928,10 +1959,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id), runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
); );
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */ /** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries] const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
.filter((e) => !settledIds.has(e.id))
.map((e) => stepOf(e.id))
.find((x) => x !== undefined);
const current = const current =
liveStep ?? liveStep ??
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined); [...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
@@ -1944,6 +1972,62 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句 * worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。 * 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
*/ */
/*
* **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
* · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
* · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
* · 有 `guide` → 照常往下问模型(半 auto)。
* 出错 → 留痕交人,不重试、不退回模型。
*/
if (current?.auto !== undefined) {
const auto = await autoRunner.run(current.auto, {
taskId,
stepId: current.item,
ownerId: (await deps.tasks.of(taskId)).ownerId,
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
quiet: false,
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' };
}
if (auto.submit !== undefined) {
const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
}
if (auto.issuedCardId !== undefined) {
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
const touched = markWorldTouched(stateRuntime, current.item);
await deps.state.save(taskId, model, parsed, touched);
/* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
if (current.view !== undefined && current.view !== '') {
await showView(taskId, current.view, touched, current.view);
}
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
}
if (auto.wrote > 0 || auto.dropped > 0) {
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
const freshScope = await deps.data.view(taskId);
const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope);
if (reswept.runtime !== stateRuntime) {
await deps.state.save(taskId, model, parsed, reswept.runtime);
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged)——
这一拍到此为止、下一拍重新导航,与「判齐失败仍要重来」那处(1749 行)同一种写法 */
return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'converged' }, status: 'running' };
}
}
if (current.guide.trim() === '') {
/* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人 */
await deps.memory.append({
taskId,
kind: 'result',
title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
detail: { auto: current.item },
});
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
}
}
/** /**
* **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。 * **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。
* 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。 * 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。