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
@@ -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 那段)。