Archived
调优:并发推进的真根因 + 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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/** CMMN 子集解析、生命周期与 DMN —— domain 测试零 mock(AM 03 / 04) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DmnDecisionTable, DmnModel } from '@contracts';
|
||||
import type { CaseRuntime } from '../internal/domain/lifecycle.ts';
|
||||
import { CmmnParseError, parseCmmn } from '../internal/domain/cmmn-parse.ts';
|
||||
import {
|
||||
autoCompleteReady,
|
||||
@@ -286,3 +287,29 @@ describe('DRD —— 缺口演绎与拓扑序', () => {
|
||||
expect(() => evaluationOrder(cyclic, 'a')).toThrow(/成环/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('先验迁移再动手 —— 不合法的行动不留痕迹', () => {
|
||||
it('Available → Disabled 是非法迁移,transitionItem 抛错', () => {
|
||||
const runtime: CaseRuntime = {
|
||||
items: [{ id: 'T1', state: 'Available', instance: 1 }],
|
||||
milestones: [],
|
||||
state: 'Active',
|
||||
};
|
||||
// 引擎语义忠实:不私改迁移拓扑。AI 提了就拒,拒在**副作用之前**
|
||||
expect(() => transitionItem(runtime, 'T1', 'Disabled')).toThrow();
|
||||
});
|
||||
|
||||
it('不存在的计划项同样拒 —— AI 可能报一个图上没有的 id', () => {
|
||||
const runtime: CaseRuntime = { items: [], milestones: [], state: 'Active' };
|
||||
expect(() => transitionItem(runtime, '不存在', 'Active')).toThrow();
|
||||
});
|
||||
|
||||
it('Available → Active 合法', () => {
|
||||
const runtime: CaseRuntime = {
|
||||
items: [{ id: 'T1', state: 'Available', instance: 1 }],
|
||||
milestones: [],
|
||||
state: 'Active',
|
||||
};
|
||||
expect(transitionItem(runtime, 'T1', 'Active').items[0]?.state).toBe('Active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
|
||||
const done = transitionItem(runtime, action.itemId, 'Completed');
|
||||
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
|
||||
if (await deps.tools.isWriteEffect(action.tool)) {
|
||||
await deps.tools.registerIntent({
|
||||
@@ -126,7 +128,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
input: action.input,
|
||||
label: `${action.itemId}:${action.tool}`,
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Completed'), status: 'running' };
|
||||
return { runtime: done, status: 'running' };
|
||||
}
|
||||
const result = await deps.tools.invoke({
|
||||
tool: action.tool,
|
||||
@@ -141,13 +143,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
detail: { output: result.output, error: result.error?.message },
|
||||
});
|
||||
return {
|
||||
runtime: transitionItem(runtime, action.itemId, result.ok ? 'Completed' : 'Failed'),
|
||||
runtime: result.ok ? done : transitionItem(runtime, action.itemId, 'Failed'),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
case 'human': {
|
||||
// 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提
|
||||
/*
|
||||
* 调人 = 立监听听人:调用即 Active(阻塞),回传驱动前提。
|
||||
*
|
||||
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡。
|
||||
* 反过来写(先发卡后迁移)会让每一次被拒的回合都白发一张卡 —— 卡收不回,
|
||||
* 负责人的工作台上就堆起一摞谁也不知道该怎么答的「请处理」。
|
||||
*/
|
||||
const advanced = transitionItem(runtime, action.itemId, 'Active');
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
@@ -161,7 +170,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: transitionItem(runtime, action.itemId, 'Active'), status: 'awaiting' };
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'listen': {
|
||||
@@ -274,7 +283,29 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (item === undefined) throw new ForbiddenTerminalError('owner');
|
||||
}
|
||||
|
||||
const applied = await applyAction(taskId, action, runtime);
|
||||
/*
|
||||
* 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);
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user