From 19173205849f665d7f042d3de6bbb1d812f6118c Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 23:02:41 +0800 Subject: [PATCH] =?UTF-8?q?Mastra=20=E5=8C=96=E5=89=8D=E7=BD=AE:loop=20?= =?UTF-8?q?=E4=B8=89=E4=BF=AE=20=E2=80=94=E2=80=94=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E5=8F=91=E7=9C=9F=E9=AA=8C=E6=94=B6=E5=8D=A1?= =?UTF-8?q?=E3=80=81=E4=BA=8B=E4=BB=B6=E5=8F=AA=E8=AE=B0=E4=B8=8D=E8=B7=91?= =?UTF-8?q?=E3=80=81=E8=BD=A8=E8=BF=B9=E5=96=82=E5=9B=9E=E5=8F=99=E4=BA=8B?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 勘察(两路 code-explorer)坐实 loop 范式三处真缺口,本轮先修(用户拍板), 与 Mastra 化解耦、按外科手术做(活动内 MVP 期高内聚不过度抽象): ① 提交放行走不到 released:submit/判齐只返回 submitted,task 层当 running 处理,任务原地重跑到预算耗尽(exhausted 而非 released)。 修:提交 = 发**验收卡**挂起等裁决(raiseAcceptance 唯一出口:发卡、立监听、 在途验收入状态信封 sys.loop_state.pending,迁移 0015);判齐重入不发第二张卡; 裁决 pass → finished released(选口随放行带回),reject → finished 不放行。 TurnOutcome 增 finished/released/selectedPorts,engines 接线透传。 ② onEvent 双拍:自跑一整拍后 deliver 的 tick 再跑一拍 —— 每次人卡回传多耗 一次不计 steps/activeMs 预算的模型决策。修:onEvent **只记不跑** (登记 occur / 收验收裁决入信封,存快照即返),推进统一归 tick,预算一个口径。 ③ Agent 每拍失忆:memory.window 定义了但零调用,任务自身轨迹从不喂回 prompt。 修:叙事面新增【本任务最近轨迹】段,窗口大小取活动声明的 manifest.declarations.harness.memoryWindow(活动侧唯一记忆配置,不再硬编码)。 测试:turn-runner-acceptance 八例;全量绿,五闸退出码过;loop CONTRACT.md 同步。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../api/src/modules/loop-engine/contract.d.ts | 12 +- .../api/src/modules/loop-engine/port.d.ts | 17 +- apps/api/src/app/wiring/engines.ts | 19 +- apps/api/src/modules/loop-engine/CONTRACT.md | 8 + .../__tests__/turn-runner-acceptance.test.ts | 163 ++++++++++++++++++ apps/api/src/modules/loop-engine/contract.ts | 12 +- .../internal/adapter/loop-state-schema.ts | 2 + .../internal/adapter/loop-state-store.ts | 10 +- .../internal/usecase/turn-runner.ts | 91 ++++++++-- apps/api/src/modules/loop-engine/port.ts | 24 ++- .../0015_loop_pending_acceptance.sql | 3 + db/migrations/meta/_journal.json | 7 + 12 files changed, 340 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts create mode 100644 db/migrations/0015_loop_pending_acceptance.sql diff --git a/api-report/apps/api/src/modules/loop-engine/contract.d.ts b/api-report/apps/api/src/modules/loop-engine/contract.d.ts index 891fb71..cad3d5f 100644 --- a/api-report/apps/api/src/modules/loop-engine/contract.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/contract.d.ts @@ -76,8 +76,11 @@ export type TurnOutcome = { readonly enabled: EnabledSet; readonly navigation: Navigation; readonly action?: AgentAction; - readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked'; + readonly status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; readonly blockedReason?: string; + /** 终局唯放行:finished 时带裁决 —— released=true 才算走完,选口随放行确认 */ + readonly released?: boolean; + readonly selectedPorts?: Record; }; export type LoopEngineService = { /** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */ @@ -88,11 +91,16 @@ export type LoopEngineService = { /** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ runTurn(taskId: TaskId): Promise; /** 事件驱动续跑:数据 / 时间 / 人 / 控制 */ + /** + * 事件到达:**只记不跑**(登记 occur / 收验收裁决,存快照即返)。 + * 从前这里自跑一整拍、deliver 随后的 tick 又跑一拍 —— 每次人卡回传 + * 多耗一次不计 steps/activeMs 预算的模型决策。推进统一归 tick。 + */ onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record; - }): Promise; + }): Promise; /** DMN 确定性求值 —— 能算的不问 LLM */ decide(taskId: TaskId, decision: string): Promise; /** 驳回:相关计划项以新实例重新入场(迁移不是状态) */ diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts index ee74196..3ae272a 100644 --- a/api-report/apps/api/src/modules/loop-engine/port.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/port.d.ts @@ -10,11 +10,12 @@ export type Clock = { }; /** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */ export type LoopStateStore = { - save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise; + save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime, pending?: PendingAcceptance | null): Promise; load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime; + pending?: PendingAcceptance; } | undefined>; }; /** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */ @@ -69,6 +70,15 @@ export type ToolGateway = { label: string; }): Promise; }; +/** + * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— + * 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。 + */ +export type PendingAcceptance = { + readonly acceptanceCardId: string; + readonly verdict?: 'pass' | 'reject'; + readonly selectedPorts?: Record; +}; export type CardGateway = { issue(input: { taskId: TaskId; @@ -133,6 +143,11 @@ export type MemoryRecorder = { layer: string; statement: string; }>>; + /** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */ + window(taskId: TaskId): Promise>; }; export type TaskContext = { of(taskId: TaskId): Promise<{ diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 4fa6308..5a7b558 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -425,6 +425,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { append: memoryRecorder.append as never, loadActivityMemory: async (activityId: string, limit: number) => (await deps.memory.loadActivityMemory(activityId, { limit })).map((m) => ({ layer: m.layer, statement: m.statement })), + // 任务自身的最近轨迹:窗口大小是活动声明的唯一 Harness 记忆配置(manifest.declarations.harness.memoryWindow) + window: async (taskId) => { + const { activityId } = await taskContext.of(taskId); + const size = (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow; + return (await deps.memory.window(taskId, size)).map((m) => ({ kind: m.kind, title: m.title })); + }, }, tasks: taskContext, clock, @@ -508,11 +514,18 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { load: (taskId, model) => loop.load(taskId, model as never).then(() => undefined), advance: async (taskId) => { const outcome = await loop.runTurn(taskId); - return { status: outcome.status, ...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }) }; + return { + status: outcome.status, + ...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }), + // 终局唯放行:runTurn 依在途验收的裁决给出 finished 时,released/选口一并带回 + ...(outcome.released === undefined ? {} : { released: outcome.released }), + ...(outcome.selectedPorts === undefined ? {} : { selectedPorts: outcome.selectedPorts }), + }; }, + // 只记不跑:登记事件/收裁决即返,推进统一归 deliver 随后的 tick —— 预算一个口径 onEvent: async (taskId, event) => { - const outcome = await loop.onEvent(taskId, event); - return { status: outcome.status, ...(outcome.blockedReason === undefined ? {} : { reason: outcome.blockedReason }) }; + await loop.onEvent(taskId, event); + return { status: 'running' as const }; }, }, flow: { diff --git a/apps/api/src/modules/loop-engine/CONTRACT.md b/apps/api/src/modules/loop-engine/CONTRACT.md index 6332fb7..7c048be 100644 --- a/apps/api/src/modules/loop-engine/CONTRACT.md +++ b/apps/api/src/modules/loop-engine/CONTRACT.md @@ -38,4 +38,12 @@ 调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势 - **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事; `mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真 + - **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady) + 不再返回 `submitted` 空转 —— 发**验收卡**、立监听、在途验收入状态信封 + (`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍 + 定终局:pass → `finished` + `released: true`(选口随放行带回),reject → 不放行 + - **onEvent 只记不跑**(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返; + 推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径 + - **任务轨迹喂回叙事面**(三修③):`memory.window(taskId)` 的最近轨迹进 + 【本任务最近轨迹】段,窗口大小 = 活动声明的 `harness.memoryWindow`,Agent 每拍不失忆 - **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场 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 new file mode 100644 index 0000000..a3f5d36 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -0,0 +1,163 @@ +/** + * loop 的「提交放行」三修(2026-07-28,Mastra 化前置): + * ① 提交/判齐 = 发**验收卡**挂起等裁决 —— 从前只返回 submitted,任务原地重跑到预算耗尽; + * ② onEvent **只记不跑** —— 从前自跑一拍、tick 再跑一拍,每次回传多耗一次不计预算的模型决策; + * ③ 任务自身最近轨迹喂回叙事面 —— 从前 Agent 每拍失忆。 + */ +import { describe, expect, it } from 'vitest'; +import type { TaskId } from '@contracts'; +import { createTurnRunner } from '../internal/usecase/turn-runner.ts'; +import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts'; +import type { PendingAcceptance } from '../port.ts'; +import type { CaseModel } from '../internal/domain/cmmn-parse.ts'; +import type { CaseRuntime } from '../internal/domain/lifecycle.ts'; + +const TASK = 't-1' as TaskId; + +/** 空案例模型:必做项全无 + autoComplete → 一进 runTurn 就「判齐」 */ +const READY_MODEL: CaseModel = { name: '空案', autoComplete: true, items: [], sentries: [] } as never; +const READY_RUNTIME: CaseRuntime = { items: [] } as never; + +/** 一个未完成必做项:走 Agent 决断路径(叙事面测试用) */ +const OPEN_MODEL: CaseModel = { + name: '有活没干完', + autoComplete: true, + items: [{ id: 'i1', name: '干活', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }], + sentries: [], +} as never; +const OPEN_RUNTIME: CaseRuntime = { items: [{ id: 'i1', state: 'Available' }] } as never; + +type Harness = { + runner: ReturnType; + readonly issued: Array<{ workOrder: string; card: Record }>; + readonly saves: Array<{ pending: PendingAcceptance | null | undefined }>; + readonly decided: string[]; + envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance }; +}; + +function harness(over: { parsed?: CaseModel; runtime?: CaseRuntime; pending?: PendingAcceptance; trail?: Array<{ kind: string; title: string }> } = {}): Harness { + const issued: Harness['issued'] = []; + const saves: Harness['saves'] = []; + const decided: string[] = []; + const h: Harness = { issued, saves, decided, runner: undefined as never, envelope: undefined as never }; + h.envelope = { + model: { cmmnXml: '', narrative: '说明书' }, + parsed: over.parsed ?? READY_MODEL, + runtime: over.runtime ?? READY_RUNTIME, + ...(over.pending === undefined ? {} : { pending: over.pending }), + }; + const deps: TurnRunnerDeps = { + state: { + save: async (_t, model, parsed, runtime, pending) => { + saves.push({ pending }); + h.envelope = { model, parsed, runtime, ...(pending == null ? {} : { pending }) }; + }, + load: async () => h.envelope as never, + }, + sfeel: { condition: () => true, expression: () => undefined } as never, + data: { view: async () => ({}) } as never, + agent: { + decide: async (input: { narrative: string }) => { + decided.push(input.narrative); + return { kind: 'submit', rationale: '我判齐了' } as never; + }, + } as never, + tools: { invoke: async () => ({ ok: true }), isWriteEffect: async () => false, registerIntent: async () => undefined } as never, + cards: { + issue: async (input: { workOrder: string; card: Record }) => { + issued.push({ workOrder: input.workOrder, card: input.card }); + return { id: `card-${issued.length}` }; + }, + } as never, + listeners: { register: async () => ({ id: 'lsn-1' }) } as never, + memory: { + append: async () => undefined, + loadActivityMemory: async () => [], + window: async () => over.trail ?? [], + } as never, + views: { push: async () => undefined } as never, + skills: { index: async () => [], stamp: async () => [] } as never, + tasks: { of: async () => ({ taskId: TASK, activityId: 'a-1', ownerId: 's-1', humanCallCap: 9 }) } as never, + clock: { now: () => new Date('2026-07-28T00:00:00Z') } as never, + }; + h.runner = createTurnRunner(deps); + return h; +} + +describe('提交放行 —— 终局唯放行,卡只发一张', () => { + it('判齐即发验收卡挂起(不再返回 submitted 空转),在途验收入信封', async () => { + const h = harness(); + const outcome = await h.runner.runTurn(TASK); + expect(outcome.status).toBe('awaiting'); + expect(h.issued).toEqual([ + { workOrder: 'acceptance', card: { title: '收口验收', basis: [{ k: '提交理由', v: '必做项全部完成,无进行中项' }] } }, + ]); + expect(h.envelope.pending?.acceptanceCardId).toBe('card-1'); + expect(h.decided).toHaveLength(0); // 判齐不问 Agent + }); + + it('在途验收未裁决时再唤起:继续等,不发第二张卡', async () => { + const h = harness({ pending: { acceptanceCardId: 'card-9' } }); + const outcome = await h.runner.runTurn(TASK); + expect(outcome.status).toBe('awaiting'); + expect(h.issued).toHaveLength(0); + }); + + it('Agent 主动 submit 也走同一条路:发卡一张、状态 awaiting', async () => { + const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME }); + const outcome = await h.runner.runTurn(TASK); + expect(outcome.status).toBe('awaiting'); + expect(h.issued).toHaveLength(1); + expect(h.issued[0]!.workOrder).toBe('acceptance'); + }); + + it('裁决 pass → finished released,选口带回,信封清空', async () => { + const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'pass', selectedPorts: { 议价结果: 'won' } } }); + const outcome = await h.runner.runTurn(TASK); + expect(outcome.status).toBe('finished'); + expect(outcome.released).toBe(true); + expect(outcome.selectedPorts).toEqual({ 议价结果: 'won' }); + expect(h.saves.at(-1)?.pending).toBeNull(); + }); + + it('裁决 reject → finished 不放行', async () => { + const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'reject' } }); + const outcome = await h.runner.runTurn(TASK); + expect(outcome.status).toBe('finished'); + expect(outcome.released).toBe(false); + }); +}); + +describe('onEvent —— 只记不跑', () => { + it('验收裁决到达:落进信封即返,不跑模型(推进归 tick,预算一个口径)', async () => { + const h = harness({ pending: { acceptanceCardId: 'card-9' } }); + await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'pass', selectedPorts: { 议价结果: 'won' } } }); + expect(h.decided).toHaveLength(0); + expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'pass', selectedPorts: { 议价结果: 'won' } }); + }); + + it('无关人卡回传不动在途验收', async () => { + const h = harness({ pending: { acceptanceCardId: 'card-9' } }); + await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-other', payload: { values: { x: 1 } } }); + expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' }); + expect(h.decided).toHaveLength(0); + }); +}); + +describe('叙事面 —— 任务自身轨迹喂回', () => { + it('窗口有轨迹时,Agent 收到【本任务最近轨迹】段', async () => { + const h = harness({ + parsed: OPEN_MODEL, + runtime: OPEN_RUNTIME, + trail: [ + { kind: 'thought', title: '上一拍我调了历史价' }, + { kind: 'result', title: '查得 3 条锚点' }, + ], + }); + await h.runner.runTurn(TASK); + expect(h.decided).toHaveLength(1); + expect(h.decided[0]).toContain('【本任务最近轨迹】'); + expect(h.decided[0]).toContain('- [thought] 上一拍我调了历史价'); + expect(h.decided[0]).toContain('- [result] 查得 3 条锚点'); + }); +}); diff --git a/apps/api/src/modules/loop-engine/contract.ts b/apps/api/src/modules/loop-engine/contract.ts index 42741a5..81a9f4e 100644 --- a/apps/api/src/modules/loop-engine/contract.ts +++ b/apps/api/src/modules/loop-engine/contract.ts @@ -77,8 +77,11 @@ export type TurnOutcome = { readonly enabled: EnabledSet; readonly navigation: Navigation; readonly action?: AgentAction; - readonly status: 'running' | 'awaiting' | 'submitted' | 'blocked'; + readonly status: 'running' | 'awaiting' | 'submitted' | 'finished' | 'blocked'; readonly blockedReason?: string; + /** 终局唯放行:finished 时带裁决 —— released=true 才算走完,选口随放行确认 */ + readonly released?: boolean; + readonly selectedPorts?: Record; }; export type LoopEngineService = { @@ -90,7 +93,12 @@ export type LoopEngineService = { /** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ runTurn(taskId: TaskId): Promise; /** 事件驱动续跑:数据 / 时间 / 人 / 控制 */ - onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record }): Promise; + /** + * 事件到达:**只记不跑**(登记 occur / 收验收裁决,存快照即返)。 + * 从前这里自跑一整拍、deliver 随后的 tick 又跑一拍 —— 每次人卡回传 + * 多耗一次不计 steps/activeMs 预算的模型决策。推进统一归 tick。 + */ + onEvent(taskId: TaskId, event: { kind: 'data' | 'time' | 'human' | 'control'; ref: string; payload: Record }): Promise; /** DMN 确定性求值 —— 能算的不问 LLM */ decide(taskId: TaskId, decision: string): Promise; /** 驳回:相关计划项以新实例重新入场(迁移不是状态) */ diff --git a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts index d3f1727..063ef95 100644 --- a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts +++ b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-schema.ts @@ -11,5 +11,7 @@ export const loopStateTable = sys.table('loop_state', { /** 解析后的 CMMN 子集结构 —— 免去每回合重解析 */ parsed: jsonb('parsed').notNull(), runtime: jsonb('runtime').notNull(), + /** 在途验收:提交放行后发出的验收卡与(到达后的)裁决 —— 防重复发卡、恢复时定终局 */ + pending: jsonb('pending'), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts index e99df0f..7d5821e 100644 --- a/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts +++ b/apps/api/src/modules/loop-engine/internal/adapter/loop-state-store.ts @@ -5,21 +5,22 @@ import { eq } from 'drizzle-orm'; import type { LoopModel, TaskId } from '@contracts'; import { loopModelSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; -import type { LoopStateStore } from '../../port.ts'; +import type { LoopStateStore, PendingAcceptance } from '../../port.ts'; import type { CaseModel } from '../domain/cmmn-parse.ts'; import type { CaseRuntime } from '../domain/lifecycle.ts'; import { loopStateTable } from './loop-state-schema.ts'; export function createLoopStateStore(db: Db): LoopStateStore { return { - async save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime) { - const values = { taskId, model, parsed, runtime, updatedAt: new Date() }; + async save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime, pending?: PendingAcceptance | null) { + const values = { taskId, model, parsed, runtime, pending: pending ?? null, updatedAt: new Date() }; await db .insert(loopStateTable) .values(values) .onConflictDoUpdate({ target: loopStateTable.taskId, - set: { model, parsed, runtime, updatedAt: values.updatedAt }, + // pending 省参 = 清空(在途验收是每次落笔都要如实交代的状态,不做“不传就保留”的暗默) + set: { model, parsed, runtime, pending: values.pending, updatedAt: values.updatedAt }, }); }, async load(taskId: TaskId) { @@ -30,6 +31,7 @@ export function createLoopStateStore(db: Db): LoopStateStore { model: loopModelSchema.parse(row.model), parsed: row.parsed as CaseModel, runtime: row.runtime as CaseRuntime, + ...(row.pending === null ? {} : { pending: row.pending as PendingAcceptance }), }; }, }; 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 6d49188..40a81e5 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 @@ -191,18 +191,42 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { } case 'submit': { - await deps.memory.append({ - taskId, - kind: 'thought', - title: '判定成果已齐,提交放行', - detail: { rationale: action.rationale }, - }); - // 终局唯放行:引擎不把案计划置为 Completed —— 那是 release 的事 - return { runtime, status: 'submitted' }; + // 终局唯放行:提交 = 发验收卡挂起等人裁决,不是自结(引擎不把案计划置为 Completed) + return raiseAcceptance(taskId, runtime, action.rationale); } } } + /** + * 提交放行的唯一出口:发**验收卡**、立监听、把在途验收记进状态信封。 + * 信封里有在途验收即不再发第二张(判齐可能被无关事件反复唤起 —— + * 没有这道持久痕,每次唤起都会往负责人工作台堆一张新卡)。 + */ + async function raiseAcceptance( + taskId: TaskId, + runtime: CaseRuntime, + rationale: string, + ): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> { + const context = await deps.tasks.of(taskId); + const card = await deps.cards.issue({ + taskId, + ownerId: context.ownerId, + humanCallCap: context.humanCallCap, + workOrder: 'acceptance', + card: { title: '收口验收', basis: [{ k: '提交理由', v: rationale }] }, + }); + await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' }); + const { model, parsed } = await loaded(taskId); + await deps.state.save(taskId, model, parsed, runtime, { acceptanceCardId: card.id }); + await deps.memory.append({ + taskId, + kind: 'thought', + title: '判定成果已齐,提交放行 —— 验收卡已发,等裁决', + detail: { rationale, cardId: card.id }, + }); + return { runtime, status: 'awaiting' }; + } + return { async load(taskId, model: LoopModel) { const parsed = parseCmmn(model.cmmnXml); @@ -227,10 +251,31 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { /** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ async runTurn(taskId): Promise { const { model, parsed, runtime, scope, enabled } = await publishFor(taskId); + const pending = (await loaded(taskId)).pending; - // 齐了即收:必做项完成且无进行中项 → 提交放行(仍必经放行卡) + /* + * 在途验收先于一切:裁决到了按它定终局(released 与否都是终局,不再问 Agent); + * 没到就继续等 —— 不重发卡、不空跑模型。 + */ + if (pending !== undefined) { + if (pending.verdict === undefined) { + return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' }; + } + await deps.state.save(taskId, model, parsed, runtime, null); + return { + runtime, + enabled, + navigation: { kind: 'converged' }, + status: 'finished', + released: pending.verdict === 'pass', + ...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }), + }; + } + + // 齐了即收:必做项完成且无进行中项 → 提交放行(发验收卡,终局唯放行) if (autoCompleteReady(parsed, runtime)) { - return { runtime, enabled, navigation: { kind: 'converged' }, status: 'submitted' }; + const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项'); + return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status }; } // 导航即交集:当下可为 ∩ 目标所需 @@ -254,9 +299,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { * 正文与知识引用等 AI 说要才加载 —— 一次把所有正文塞进去, * 上下文会被方法论淹掉,真正的局面反而看不见。 */ + // 任务自身的最近轨迹(窗口大小 = 活动声明的 memoryWindow)—— 没有它 Agent 每拍失忆 + const trail = await deps.memory.window(taskId); const narrative = [ model.narrative, ...(await deps.memory.loadActivityMemory(activityId, 20)).map((m) => `[${m.layer}] ${m.statement}`), + trail.length === 0 + ? '' + : `【本任务最近轨迹】\n${trail.map((t) => `- [${t.kind}] ${t.title}`).join('\n')}`, catalog.length === 0 ? '' : `【可装配的技能(目录)】\n${catalog.map((s) => `- ${s.id} ${s.label}:${s.applicability}`).join('\n')}`, @@ -345,7 +395,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { /** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */ async onEvent(taskId, event) { - const { model, parsed, runtime } = await loaded(taskId); + const { model, parsed, runtime, pending } = await loaded(taskId); await deps.memory.append({ taskId, kind: 'observation', @@ -358,9 +408,22 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { const listener = parsed.items.find((i) => i.id === event.ref && i.type.endsWith('EventListener')); if (listener !== undefined) next = occur(runtime, listener.id); - await deps.state.save(taskId, model, parsed, next); - // 前提前推:事件写数据 → 任务视图变化 → 前提求值 → 新项 Enabled - return this.runTurn(taskId); + /* + * 验收裁决到达:落进状态信封,终局由下一拍(deliver → tick → runTurn)裁定。 + * 只记不跑 —— 推进(可能含一次模型决策)统一发生在 tick,预算才有一个口径。 + */ + let nextPending = pending ?? null; + if (event.kind === 'human' && pending?.acceptanceCardId === event.ref) { + const verdict = event.payload['verdict'] === 'pass' ? ('pass' as const) : ('reject' as const); + const ports = event.payload['selectedPorts']; + nextPending = { + ...pending, + verdict, + ...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record } : {}), + }; + } + + await deps.state.save(taskId, model, parsed, next, nextPending); }, /** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */ diff --git a/apps/api/src/modules/loop-engine/port.ts b/apps/api/src/modules/loop-engine/port.ts index e57a85d..d577b3a 100644 --- a/apps/api/src/modules/loop-engine/port.ts +++ b/apps/api/src/modules/loop-engine/port.ts @@ -10,8 +10,16 @@ export type Clock = { now(): Date }; /** 运行时实例状态 —— loop 的模型定位与就绪集(OS 09 三份持久状态之一) */ export type LoopStateStore = { - save(taskId: TaskId, model: LoopModel, parsed: CaseModel, runtime: CaseRuntime): Promise; - load(taskId: TaskId): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime } | undefined>; + save( + taskId: TaskId, + model: LoopModel, + parsed: CaseModel, + runtime: CaseRuntime, + pending?: PendingAcceptance | null, + ): Promise; + load( + taskId: TaskId, + ): Promise<{ model: LoopModel; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance } | undefined>; }; /** S-FEEL —— app 注入 `@sfeel`;引擎零 LLM */ @@ -58,6 +66,16 @@ export type ToolGateway = { registerIntent(input: { taskId: TaskId; tool: string; input: Record; label: string }): Promise; }; +/** + * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— + * 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。 + */ +export type PendingAcceptance = { + readonly acceptanceCardId: string; + readonly verdict?: 'pass' | 'reject'; + readonly selectedPorts?: Record; +}; + export type CardGateway = { issue(input: { taskId: TaskId; @@ -112,6 +130,8 @@ export type MemoryRecorder = { }): Promise; /** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */ loadActivityMemory(activityId: string, limit: number): Promise>; + /** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */ + window(taskId: TaskId): Promise>; }; export type TaskContext = { diff --git a/db/migrations/0015_loop_pending_acceptance.sql b/db/migrations/0015_loop_pending_acceptance.sql new file mode 100644 index 0000000..4be3cf6 --- /dev/null +++ b/db/migrations/0015_loop_pending_acceptance.sql @@ -0,0 +1,3 @@ +-- loop 的「提交放行」修成真机制:判齐后发验收卡挂起等裁决(从前只返回 submitted, +-- 任务原地重跑直到预算耗尽)。在途的验收卡与裁决结果落状态信封,防重复发卡、供恢复判定。 +alter table sys.loop_state add column pending jsonb; diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 665cec0..3d22fa3 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1785130113169, "tag": "0014_instance_assignee", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785130113170, + "tag": "0015_loop_pending_acceptance", + "breakpoints": true } ] }