diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index d698af5..7594f8d 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -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), diff --git a/apps/api/src/modules/loop-engine/__tests__/live-step.test.ts b/apps/api/src/modules/loop-engine/__tests__/live-step.test.ts new file mode 100644 index 0000000..10867a0 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/live-step.test.ts @@ -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'); + }); +}); 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 b0407bc..6e5550b 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 @@ -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); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/domain/live-step.ts b/apps/api/src/modules/loop-engine/internal/domain/live-step.ts new file mode 100644 index 0000000..6807cc9 --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/live-step.ts @@ -0,0 +1,19 @@ +/** + * **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。 + * + * 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**, + * 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了, + * 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。 + */ +export function liveStepOf( + steps: readonly S[], + settledIds: ReadonlySet, + 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; +} 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 af2393b..5cc6190 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,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 那段)。