This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts
T
ChaiHaoandClaude Opus 5 94f31f267b 调优:并发推进的真根因 + loop 的三处行为修正
E2E 跑 loop 时 A3 一口气发了八张一模一样的「请处理」卡。逐层查下去,四处:

① **并发推进的真根因**:`pg_try_advisory_lock` 是**会话级**的,而我们经连接池取连接 ——
   加锁与解锁多半落在不同连接上,解锁解不掉自己那把;同一连接被复用时又因可重入而直接放行。
   两头都不对,于是同一任务被并发推进(现象:同一步跑两遍、同一张卡发两张、
   同一行草稿写出两条互相打架的版本)。前一轮「共用同一把锁」的修法方向对、器械错。
   改用**进程内按目标互斥**(MVP 单进程,确定且够用),推进器与派发器共用一把;
   多进程部署时再叠事务级 `pg_try_advisory_xact_lock`(随事务释放,不依赖连接归属)。
   实测:同一单从 8 张卡降到 1 张,2 回合后落到等待。

② **非法行动会把回合打死**:AI 提了不合法的生命周期迁移(Available → Disabled),
   `applyAction` 抛出、运行时没落笔、推进器一遍遍重来。引擎拒绝它是对的
   ——「语义忠实:引擎不得私改迁移拓扑」——但不该因此崩掉。
   改为:拒绝 + 留痕 + 把理由喂回下一回合的上下文。AI 提出非法行动是预期内的事。

③ **副作用抢在校验之前**:卡在迁移合法性判定**之前**就发出去了,于是每个被拒的回合
   都白发一张卡 —— 卡收不回,负责人工作台上堆起一摞没人知道怎么答的「请处理」。
   human / tool 两个分支改为**先验迁移再动手**。

④ **裸调用**:模型只回一个「请处理」。按「调用必附上下文,裸调用不得发出」(AM 06)
   把卡面结构写进出参 schema(title / ask / basis / fields 必填),提示里讲明要求,
   归一时用当下局面兜底;卡片装配也修了 —— 结构化 basis 优先,嵌套对象转 JSON,
   不再出现一行 `[object Object]`。
   实测卡面:「请确认 NE555P 历史报价并判定采用策略」+ 三个回填字段。

gates: tsc / eslint / depcruise(336 模块)/ api-report / 429 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 11:31:41 +08:00

406 lines
15 KiB
TypeScript

/**
* loop 回合驱动 —— 语义引擎 × Agent 回路(编码规范 §5.3 / §6.3;语义出处 AM 04)。
*
* 定位 → 择向 → 决断 → 行动 → 写数据 → 回到定位。
* **回合由 usecase 驱动,不放开自由 maxSteps;预算在环外由 Harness 强制。**
*/
import type { LoopModel, TaskId } from '@contracts';
import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract.ts';
import { ForbiddenTerminalError } from '../../contract.ts';
import type {
AgentPort,
CardGateway,
Clock,
ListenerRegistrar,
LoopStateStore,
MemoryRecorder,
SkillGateway,
ViewGateway,
Sfeel,
TaskContext,
TaskDataGateway,
ToolGateway,
} from '../../port.ts';
import { parseCmmn } from '../domain/cmmn-parse.ts';
import type { CaseRuntime } from '../domain/lifecycle.ts';
import {
autoCompleteReady,
initialRuntime,
navigate,
occur,
publishEnabled,
reenter as reenterItem,
transitionItem,
} from '../domain/lifecycle.ts';
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
export type TurnRunnerDeps = {
readonly state: LoopStateStore;
readonly sfeel: Sfeel;
readonly data: TaskDataGateway;
readonly agent: AgentPort;
readonly tools: ToolGateway;
readonly cards: CardGateway;
readonly listeners: ListenerRegistrar;
readonly memory: MemoryRecorder;
readonly views: ViewGateway;
readonly skills: SkillGateway;
readonly tasks: TaskContext;
readonly clock: Clock;
};
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
async function loaded(taskId: TaskId) {
const found = await deps.state.load(taskId);
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
return found;
}
/** 变化面:任务视图里有值的键 —— onPart 的「件到」判据 */
function changedRefs(scope: Record<string, unknown>): Set<string> {
return new Set(Object.keys(scope).filter((k) => scope[k] !== undefined && scope[k] !== null));
}
async function publishFor(taskId: TaskId) {
const { model, parsed, runtime } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const gaps = parsed.items
.filter((i) => !i.discretionary && runtime.items.find((r) => r.id === i.id)?.state !== 'Completed')
.map((i) => i.name);
return {
model,
parsed,
runtime,
scope,
enabled: publishEnabled(parsed, runtime, scope, changedRefs(scope), deps.sfeel.condition, gaps),
};
}
async function applyAction(taskId: TaskId, action: AgentAction, runtime: CaseRuntime): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string }> {
const context = await deps.tasks.of(taskId);
switch (action.kind) {
case 'start': {
// manualStart —— 进入 Active 永远出自 Agent 的裁量启动,留痕
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `manualStart:${action.itemId}`,
detail: { by: 'agent' },
});
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'running' };
}
case 'plan': {
await deps.memory.append({ taskId, kind: 'engine-transition', title: `规划裁量项入场:${action.itemId}` });
return { runtime: transitionItem(runtime, action.itemId, 'Enabled'), status: 'running' };
}
case 'disable': {
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `弃用计划项:${action.itemId}`,
detail: { reason: action.reason },
});
return { runtime: transitionItem(runtime, action.itemId, 'Disabled'), status: 'running' };
}
case 'judge': {
// 语义性前提的裁量评估 —— 评估留痕,可回放可归因
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `语义性前提评估:${action.criterion} → ${action.satisfied ? '满足' : '未满足'}`,
detail: { rationale: action.rationale },
});
return { runtime, status: 'running' };
}
case 'tool': {
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
const done = transitionItem(runtime, action.itemId, 'Completed');
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
if (await deps.tools.isWriteEffect(action.tool)) {
await deps.tools.registerIntent({
taskId,
tool: action.tool,
input: action.input,
label: `${action.itemId}:${action.tool}`,
});
return { runtime: done, status: 'running' };
}
const result = await deps.tools.invoke({
tool: action.tool,
input: action.input,
activityId: context.activityId,
taskId,
});
await deps.memory.append({
taskId,
kind: 'result',
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
detail: { output: result.output, error: result.error?.message },
});
return {
runtime: result.ok ? done : transitionItem(runtime, action.itemId, 'Failed'),
status: 'running',
};
}
case 'human': {
/*
* 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提。
*
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡。
* 反过来写(先发卡后迁移)会让每一次被拒的回合都白发一张卡 —— 卡收不回,
* 负责人的工作台上就堆起一摞谁也不知道该怎么答的「请处理」。
*/
const advanced = transitionItem(runtime, action.itemId, 'Active');
const card = await deps.cards.issue({
taskId,
ownerId: context.ownerId,
humanCallCap: context.humanCallCap,
workOrder: action.workOrder,
card: action.card,
});
await deps.listeners.register({
taskId,
eventType: 'human',
keyFilter: { cardId: card.id },
deadline: 'P3D',
});
return { runtime: advanced, status: 'awaiting' };
}
case 'listen': {
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
await deps.listeners.register({
taskId,
eventType: action.eventType,
keyFilter: action.keyFilter,
deadline: action.deadline,
});
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
detail: action.keyFilter,
});
return { runtime, status: 'awaiting' };
}
case 'submit': {
await deps.memory.append({
taskId,
kind: 'thought',
title: '判定成果已齐,提交放行',
detail: { rationale: action.rationale },
});
// 终局唯放行:引擎不把案计划置为 Completed —— 那是 release 的事
return { runtime, status: 'submitted' };
}
}
}
return {
async load(taskId, model: LoopModel) {
const parsed = parseCmmn(model.cmmnXml);
const runtime = initialRuntime(parsed);
await deps.state.save(taskId, model, parsed, runtime);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `装载案例模型:${parsed.name}(${parsed.items.length} 个计划项)`,
});
return runtime;
},
async runtime(taskId) {
return (await deps.state.load(taskId))?.runtime;
},
async publish(taskId) {
return (await publishFor(taskId)).enabled;
},
/** 一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
async runTurn(taskId): Promise<TurnOutcome> {
const { model, parsed, runtime, scope, enabled } = await publishFor(taskId);
// 齐了即收:必做项完成且无进行中项 → 提交放行(仍必经放行卡)
if (autoCompleteReady(parsed, runtime)) {
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'submitted' };
}
// 导航即交集:当下可为 ∩ 目标所需
const navigation = navigate(enabled, new Set(enabled.entries.map((e) => e.id)));
if (navigation.kind === 'breakpoint') {
// 断点即人环 —— 由 Agent 定夺调人还是立监听,判定留痕
await deps.memory.append({
taskId,
kind: 'thought',
title: '依赖断点:当下可为 ∩ 目标所需 = 空',
detail: { gaps: navigation.gaps },
});
}
const activityId = (await deps.tasks.of(taskId)).activityId;
const catalog = await deps.skills.index(activityId);
/*
* 叙事面 = 案例说明书 + 活动记忆选摘 + **技能目录**。
* 渐进披露(AM 08):常驻上下文只给「有哪些方法、何时适用」,
* 正文与知识引用等 AI 说要才加载 —— 一次把所有正文塞进去,
* 上下文会被方法论淹掉,真正的局面反而看不见。
*/
const narrative = [
model.narrative,
...(await deps.memory.loadActivityMemory(activityId, 20)).map((m) => `[${m.layer}] ${m.statement}`),
catalog.length === 0
? ''
: `【可装配的技能(目录)】\n${catalog.map((s) => `- ${s.id} ${s.label}:${s.applicability}`).join('\n')}`,
]
.filter((line) => line !== '')
.join('\n\n');
// 装配入信封:这一回合手上有哪些技能及其版本,记进任务记忆 —— 回放与归因依赖之
if (catalog.length > 0) {
const stamp = await deps.skills.stamp(activityId, catalog.map((s) => s.id));
await deps.memory.append({
taskId,
kind: 'thought',
title: `装配技能:${stamp.map((s) => `${s.id}@${s.version}`).join('、')}`,
detail: { skills: [...stamp] },
});
}
const action = await deps.agent.decide({ taskId, narrative, enabled, scope });
// AI 不得产生终局迁移
if (action.kind === 'start') {
const item = parsed.items.find((i) => i.id === action.itemId);
if (item === undefined) throw new ForbiddenTerminalError('owner');
}
/*
* AI 提的行动可能**不合法**(违反生命周期迁移拓扑、越权、预算触顶)。
* 引擎拒绝它是对的 —— 「语义忠实:引擎不得私改迁移拓扑」;但**不能因此死掉**:
* 抛出去会让这一回合的副作用(卡已发出)留在世界上、运行时却没落笔,
* 推进器于是一遍遍重来,每遍再发一张卡。
*
* 正确做法:拒绝 + 留痕 + 把拒绝理由喂回下一回合的上下文。
* AI 提出非法行动是预期内的事,引擎的职责是不执行,不是崩掉。
* 反复提不出合法行动由 Harness 兜底(步预算与停滞熔断),那才是它该管的事。
*/
let applied: { runtime: CaseRuntime; status: TurnOutcome['status']; reason?: string };
try {
applied = await applyAction(taskId, action, runtime);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
await deps.memory.append({
taskId,
kind: 'result',
title: `行动被拒:${reason}`,
detail: { action, hint: '请在「当下可为」里另选一项,或改用调人 / 立监听' },
});
return { runtime, enabled, navigation, action, status: 'running' };
}
await deps.state.save(taskId, model, parsed, applied.runtime);
/*
* 每回合推一次案例全景 —— 呈现是投影,失败只丢这一帧,不影响回路推进。
* 画面的素材全部来自语义引擎(阶段态 / 当下可为 / 缺口),零 LLM。
*/
try {
await deps.views.push({
taskId,
activityId: (await deps.tasks.of(taskId)).activityId,
template: 'case-overview',
scope: {
...scope,
'case:stages': parsed.items
.filter((i) => i.type === 'stage')
.map((i) => ({ name: i.name, state: applied.runtime.items.find((r) => r.id === i.id)?.state ?? '—' })),
'case:enabled': enabled.entries.map((e) => ({ name: e.name, state: e.requiresManualStart ? '待启动' : '可进行' })),
'case:gaps': enabled.gaps.map((g) => ({ name: g, state: '未满足' })),
gapCount: enabled.gaps.length,
enabledCount: enabled.entries.length,
activeStage: enabled.activeStages.join('、') || '—',
},
});
} catch {
// 呈现失败不影响回路:画面丢一帧,事实一件不少
}
return {
runtime: applied.runtime,
enabled,
navigation,
action,
status: applied.status,
...(applied.reason === undefined ? {} : { blockedReason: applied.reason }),
};
},
/** 事件驱动续跑:挂起期间事件不丢,恢复即补投 */
async onEvent(taskId, event) {
const { model, parsed, runtime } = await loaded(taskId);
await deps.memory.append({
taskId,
kind: 'observation',
title: `事件到达(${event.kind}):${event.ref}`,
detail: event.payload,
});
let next = runtime;
// 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed
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);
},
/** 能算的不问 LLM:输入齐备的决策表确定性求值,命中行留痕 */
async decide(taskId, decision) {
const { model } = await loaded(taskId);
const scope = await deps.data.view(taskId);
const order = evaluationOrder(model.dmn, decision);
const enriched: Record<string, unknown> = { ...scope };
let last = undefined as ReturnType<typeof evaluateTable> | undefined;
for (const step of order) {
const table = model.dmn.tables.find((t) => t.id === step);
if (table === undefined) continue;
const result = evaluateTable(table, enriched, deps.sfeel.expression, deps.sfeel.unaryTest);
last = result;
if (result.kind !== 'decided') break;
Object.assign(enriched, result.trace.outputs);
await deps.memory.append({
taskId,
kind: 'evaluation',
title: `DMN 求值:${table.label} → 命中 ${result.trace.hitRules.join('、')}`,
detail: { inputs: result.trace.inputs, outputs: result.trace.outputs, table: table.id },
});
}
return last ?? { kind: 'no-match', inputs: {} };
},
/** 驳回:相关计划项以新实例重新入场(repetitionRule),记为纠正信号 */
async reenter(taskId, itemId) {
const { model, parsed, runtime } = await loaded(taskId);
const next = reenterItem(runtime, itemId);
await deps.state.save(taskId, model, parsed, next);
await deps.memory.append({
taskId,
kind: 'engine-transition',
title: `驳回重入:${itemId}(新实例)`,
});
return next;
},
};
}