Mastra 化前置:loop 三修 —— 提交放行发真验收卡、事件只记不跑、轨迹喂回叙事面

勘察(两路 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 23:02:41 +08:00
co-authored by Claude Fable 5
parent 967627d753
commit 1917320584
12 changed files with 340 additions and 28 deletions
@@ -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<typeof createTurnRunner>;
readonly issued: Array<{ workOrder: string; card: Record<string, unknown> }>;
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: '<xml/>', 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<string, unknown> }) => {
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 条锚点');
});
});