Archived
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:
@@ -76,6 +76,11 @@
|
||||
去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。
|
||||
- **画面跟着这一拍发出去的卡走**(同日):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 的里程碑没有执行体,
|
||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||
发这种事件 —— 于是里程碑永远 `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);
|
||||
});
|
||||
});
|
||||
|
||||
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 type { CaseModel } from '../domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
||||
import { createAutoRunner } from './auto-runner.ts';
|
||||
import {
|
||||
achieveMilestones,
|
||||
autoCompleteReady,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
transitionItem,
|
||||
} from '../domain/lifecycle.ts';
|
||||
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
||||
import { liveStepOf } from '../domain/live-step.ts';
|
||||
import { phaseProgress } from '../domain/phase.ts';
|
||||
|
||||
export type TurnRunnerDeps = {
|
||||
@@ -426,6 +428,14 @@ const STALL_LIMIT = 5;
|
||||
const STALL_WINDOW = 60;
|
||||
|
||||
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) {
|
||||
const found = await deps.state.load(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);
|
||||
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({
|
||||
taskId,
|
||||
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),
|
||||
);
|
||||
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
|
||||
const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries]
|
||||
.filter((e) => !settledIds.has(e.id))
|
||||
.map((e) => stepOf(e.id))
|
||||
.find((x) => x !== undefined);
|
||||
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
|
||||
const current =
|
||||
liveStep ??
|
||||
[...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)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
||||
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
||||
*/
|
||||
/*
|
||||
* **配方段**(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 那段)。
|
||||
|
||||
Reference in New Issue
Block a user