Archived
资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -104,10 +104,26 @@
|
||||
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
|
||||
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
|
||||
不再返回 `submitted` 空转 —— 发**验收卡**、立监听、在途验收入状态信封
|
||||
(`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍
|
||||
定终局:pass → `finished` + `released: true`(选口随放行带回),reject → 不放行
|
||||
(`loop_state.pending`,迁移 0015);判齐重入不发第二张卡;裁决到达后由下一拍分岔:
|
||||
pass → `finished` + `released: true`(选口随放行带回);**非 pass = 打回重做,
|
||||
不是终局**(2026-08-22 修死:从前非 pass 也走 `finished` 分支,task 层把
|
||||
「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,却再没有事件
|
||||
叫醒它,一单就此卡死)——意见(`PendingAcceptance.comment`,onEvent 从裁决载荷
|
||||
捕入信封)以「验收被打回重做:…」进轨迹,在途验收清掉,**同一拍照常问模型**;
|
||||
刚被打回的那一拍不走判齐快道(否则判齐恒真的活动会在意见没人读时原样再发一张卡),
|
||||
模型修订后重新 submit,新发的验收卡才是下一次裁决的对象
|
||||
- **onEvent 只记不跑**(三修②):事件到达只登记 occur / 收裁决入信封,存快照即返;
|
||||
推进(可能含一次模型决策)统一发生在 tick —— steps/activeMs/tokens 一个预算口径
|
||||
- **任务轨迹喂回叙事面**(三修③):`memory.window(taskId)` 的最近轨迹进
|
||||
【本任务最近轨迹】段,窗口大小 = 活动声明的 `harness.memoryWindow`,Agent 每拍不失忆
|
||||
- **驳回是迁移不是状态**:`reenter` 以 repetitionRule 新实例重新入场
|
||||
- **打回重做之后,没改过东西就不许再提交**(2026-08-23):`raiseAcceptance` 一处把关,
|
||||
模型 submit 与判齐快道两条路共用 —— 打回之后台账没动过(写/撤都算)就拒;
|
||||
软硬两段:头一次拒并指路(改台账 / 发卡问清楚要改什么),已因此拒过一次再提交就放行
|
||||
(墙要配路,不锁死单子)。起因:打回那一刻案例模型的项常常全是 `Completed`,
|
||||
模型除了原样重新 submit 无事可做,6 秒就把同一份成果递回来
|
||||
- **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23):
|
||||
`ListenerRegistrar.declared(taskId)` 给出声明过的那几种,没声明过的类型当场拒。
|
||||
与「写的授权面是台账声明」「调工具的授权面是白名单」同一条 ——
|
||||
立了没人叫醒的监听不是等待,是失联(实测:模型在依赖断点上临场立 data 监听,
|
||||
而那个活动一个监听都没声明,单子永久挂在 `awaiting-event`)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
canTransitionItem,
|
||||
IllegalTransitionError,
|
||||
initialRuntime,
|
||||
markWorldTouched,
|
||||
mayCompleteCase,
|
||||
mayTerminateCase,
|
||||
navigate,
|
||||
@@ -134,6 +135,22 @@ describe('生命周期 —— 语义忠实,引擎不得私改迁移拓扑', () =
|
||||
const next = reenter(runtime, 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')).toEqual({ id: 'T1', state: 'Available', instance: 2 });
|
||||
});
|
||||
|
||||
describe('markWorldTouched —— finish 前的证据(AM 12.2 原理一)', () => {
|
||||
it('标一个项 —— 其余项原样不动', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const next = markWorldTouched(initialRuntime(model), 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBe(true);
|
||||
expect(next.items.find((i) => i.id !== 'T1')?.worldTouched).toBeUndefined();
|
||||
});
|
||||
|
||||
it('驳回重新入场是新实例 —— 标记不带过去,得重新证明真触达过', () => {
|
||||
const model = parseCmmn(XML);
|
||||
const touched = markWorldTouched(initialRuntime(model), 'T1');
|
||||
const next = reenter(touched, 'T1');
|
||||
expect(next.items.find((i) => i.id === 'T1')?.worldTouched).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('终局唯放行与 Harness —— AI 不得产生终局迁移', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } 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';
|
||||
@@ -37,6 +37,12 @@ type Harness = {
|
||||
readonly pushed: string[];
|
||||
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
|
||||
readonly writesAtPush: number[];
|
||||
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
|
||||
readonly appended: Array<{ kind: string; title: string }>;
|
||||
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
|
||||
readonly toolCalls: string[];
|
||||
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
|
||||
readonly listened: Array<{ eventType: string }>;
|
||||
envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance };
|
||||
};
|
||||
|
||||
@@ -59,6 +65,12 @@ function harness(
|
||||
writeNoop?: boolean;
|
||||
/** 撤行时草稿层里根本没有那一行(主键写法对不上) */
|
||||
dropMisses?: boolean;
|
||||
/** doneWhen 表达式求值的桩(不给就恒真,同旧行为) */
|
||||
sfeelCondition?: (expr: string, scope: Record<string, unknown>) => boolean;
|
||||
/** 动态任务视图:按「此刻已写下几行」现算 —— 测「先写后 finish 的 doneWhen 用当下值」要它 */
|
||||
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
||||
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -68,6 +80,9 @@ function harness(
|
||||
const dropped: Harness['dropped'] = [];
|
||||
const pushed: string[] = [];
|
||||
const writesAtPush: number[] = [];
|
||||
const appended: Harness['appended'] = [];
|
||||
const toolCalls: string[] = [];
|
||||
const listened: Array<{ eventType: string }> = [];
|
||||
const h: Harness = {
|
||||
issued,
|
||||
saves,
|
||||
@@ -76,6 +91,9 @@ function harness(
|
||||
dropped,
|
||||
pushed,
|
||||
writesAtPush,
|
||||
appended,
|
||||
toolCalls,
|
||||
listened,
|
||||
runner: undefined as never,
|
||||
envelope: undefined as never,
|
||||
};
|
||||
@@ -93,9 +111,9 @@ function harness(
|
||||
},
|
||||
load: async () => h.envelope as never,
|
||||
},
|
||||
sfeel: { condition: () => true, expression: () => undefined } as never,
|
||||
sfeel: { condition: over.sfeelCondition ?? (() => true), expression: () => undefined } as never,
|
||||
data: {
|
||||
view: async () => over.scope ?? {},
|
||||
view: async () => (over.viewFn === undefined ? (over.scope ?? {}) : over.viewFn(written)),
|
||||
writable: async () => [
|
||||
{ domain: 'quote_intake', object: 'record', label: '统一受理记录', keys: ['pn'], fields: [{ name: 'eau', label: 'EAU' }] },
|
||||
],
|
||||
@@ -120,7 +138,10 @@ function harness(
|
||||
},
|
||||
} as never,
|
||||
tools: {
|
||||
invoke: async () => ({ ok: true }),
|
||||
invoke: async (input: { tool: string }) => {
|
||||
toolCalls.push(input.tool);
|
||||
return { ok: true };
|
||||
},
|
||||
isWriteEffect: async () => false,
|
||||
registerIntent: async () => undefined,
|
||||
busOf: async () => [
|
||||
@@ -137,9 +158,17 @@ function harness(
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
} as never,
|
||||
listeners: { register: async () => ({ id: 'lsn-1' }) } as never,
|
||||
listeners: {
|
||||
register: async (input: { eventType: string }) => {
|
||||
listened.push({ eventType: input.eventType });
|
||||
return { id: 'lsn-1' };
|
||||
},
|
||||
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
|
||||
} as never,
|
||||
memory: {
|
||||
append: async () => undefined,
|
||||
append: async (entry: { kind: string; title: string }) => {
|
||||
appended.push({ kind: entry.kind, title: entry.title });
|
||||
},
|
||||
loadActivityMemory: async () => [],
|
||||
window: async () => over.trail ?? [],
|
||||
} as never,
|
||||
@@ -212,11 +241,166 @@ describe('提交放行 —— 终局唯放行,卡只发一张', () => {
|
||||
expect(h.saves.at(-1)?.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('裁决 reject → finished 不放行', async () => {
|
||||
const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'reject' } });
|
||||
/*
|
||||
* **裁决非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:E04 一单点了「重做」,
|
||||
* 引擎把在途验收消费成 null、任务重新挂回 awaiting-human —— 人已经说了话,
|
||||
* 却再没有任何事件会把它叫醒,一单就此卡死)。重做的语义是「带着意见退回去再干一遍」:
|
||||
* 意见用人话进轨迹、在途验收清掉,**同一拍照常问模型** —— 模型带着意见改成果、
|
||||
* 改完重新 submit,新发的那张验收卡才是下一次裁决的对象。
|
||||
*/
|
||||
it('裁决 redo(非 pass)→ 不终局:意见进轨迹、清在途验收、同一拍照常问模型', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
pending: { acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次,优先级排得不对' },
|
||||
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('finished');
|
||||
expect(outcome.released).toBe(false);
|
||||
expect(outcome.status).not.toBe('finished');
|
||||
expect(h.decided).toHaveLength(1);
|
||||
// 在途验收第一时间清掉 —— 留着它,下一拍还会按「已裁决」再走一遍终局
|
||||
expect(h.saves[0]?.pending).toBeNull();
|
||||
expect(h.appended.some((e) => e.title.includes('打回重做') && e.title.includes('重做一次,优先级排得不对'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **打回重做之后,一个字没改就再提交 = 原样递回,当场拒**(2026-08-22,柴浩实测:
|
||||
* 点「重做」写了「重做一下」,6 秒后同一份成果原样又递上来 —— 三条裁量判据重判一遍、
|
||||
* 台账一个字没动。人看见的是「我说了重做,它当没听见」)。
|
||||
* 案例模型的项这时全是 Completed,模型除了重新 submit 无事可做 —— **措辞治不了的结构治**:
|
||||
* 提交这道口子自己看一眼「打回之后改过东西没有」,没改就不放行。
|
||||
* 与「重发已答过的卡」同族的软硬两段:头一次拒(把路指明:改台账、或问清楚要改什么),
|
||||
* 已经拒过一次还坚持原样提交就放行 —— 人还能再打回,不至于把单子锁死。
|
||||
*/
|
||||
const REDO_NOTICE = { kind: 'result', title: '验收被打回重做:优先级排得不对' };
|
||||
const WROTE = { kind: 'action', title: '写草稿:crm_coverage.plan 1 行(1 行有变化)', detail: { ledger: 'crm_coverage.plan' } };
|
||||
const REFUSED = { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' };
|
||||
|
||||
it('打回后零修改就 submit:当场拒,不发新验收卡', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('一个字没改'))).toBe(true);
|
||||
});
|
||||
|
||||
it('打回后改过台账再 submit:照常发卡', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, WROTE] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('已经因此拒过一次:再提交就放行 —— 墙要配路,不把单子锁死', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, REFUSED] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('判齐快道同一道闸:打回后没改,引擎也不替模型原样再递一次', async () => {
|
||||
// READY_MODEL 判齐恒真 —— 这条路不经模型,从前它会在意见没人读时自己再发一张卡
|
||||
const h = harness({ trail: [REDO_NOTICE] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(true);
|
||||
});
|
||||
|
||||
it('没有打回过的常路:提交照常,这道闸不出手', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [WROTE] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('判齐恒真的局面里收到 redo:这一拍不走判齐快道重发卡 —— 先让模型看见意见', async () => {
|
||||
// READY_MODEL 必做项全无 + autoComplete → 判齐恒真;从前这条快道会不问模型直接再发一张卡
|
||||
const h = harness({
|
||||
pending: { acceptanceCardId: 'card-9', verdict: 'reject' },
|
||||
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(1);
|
||||
// 这一拍引擎没有自己发新验收卡(模型看完意见后自己 submit 才发)
|
||||
expect(h.issued).toHaveLength(0);
|
||||
// 没写意见也要说明白:让模型自己复核,不是让它猜
|
||||
expect(h.appended.some((e) => e.title.includes('打回重做'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
||||
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 `itemId` 就是空串,
|
||||
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下 ——
|
||||
* **最后的活路自己被拦住,这一单就真没路了**(墙要配路,而这里连路本身都砌了墙)。
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
|
||||
* 挂不上照发,留痕注明。人工激活那道 worldTouched 门不受影响 ——
|
||||
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
|
||||
*/
|
||||
describe('发卡 —— 项号挂不上不拦', () => {
|
||||
const CARD = { kind: 'human', itemId: '', card: { title: '需要你判断下一步', ask: '接着走哪一项?' }, workOrder: 'execution' };
|
||||
|
||||
it('项号为空:卡照发,留痕注明挂不上', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CARD });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
expect(h.appended.some((e) => e.title.includes('未挂到计划项'))).toBe(true);
|
||||
});
|
||||
|
||||
it('项号编的:同样照发 —— 卡是给人的,不该因为记账挂不上就发不出去', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...CARD, itemId: '我编的项' } });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('项号挂得上:照旧挂上并点亮(worldTouched 那道门要的就是这个)', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: { ...CARD, itemId: 'GATED' } });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23,柴浩实测:
|
||||
* E04 一单在 S4 审批过后卡在依赖断点 —— 模型没写 `plan_approved_total` 那面旗,
|
||||
* S5 的入场哨兵永不成立;它于是临场「立一个 data 监听等执行回执」,而 E04 声明的
|
||||
* 监听是**空的**、这一单也不会有任何 data 事件进来,单子就此永久挂在 awaiting-event,
|
||||
* 15 分钟一动不动)。与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:
|
||||
* **没声明过的监听立不得** —— 立了也没人来叫醒它,那不是等待,是失联。
|
||||
*/
|
||||
describe('立监听 —— 授权面是清单声明', () => {
|
||||
const LISTEN = { kind: 'listen', itemId: 'i1', eventType: 'data', keyFilter: { kind: 'receipt' }, deadline: 'P3D' };
|
||||
|
||||
it('活动一个监听都没声明:当场拒,并指出该走哪条路', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: LISTEN, declaredListeners: [] });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('没声明'))).toBe(true);
|
||||
});
|
||||
|
||||
it('声明过这一类:照立不拦', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: LISTEN,
|
||||
declaredListeners: [{ eventType: 'data', keyFilter: { kind: 'receipt' } }],
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('声明的是别的类(只声明了 time,却要立 data):照样拒', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: LISTEN,
|
||||
declaredListeners: [{ eventType: 'time', keyFilter: { kind: 'deadline' } }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +418,12 @@ describe('onEvent —— 只记不跑', () => {
|
||||
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' });
|
||||
expect(h.decided).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('redo 裁决连意见一起落进信封 —— 重做那一拍模型要看得见人写了什么', async () => {
|
||||
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
|
||||
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'redo', comment: '重做一次' } });
|
||||
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('叙事面 —— 任务自身轨迹喂回', () => {
|
||||
@@ -605,6 +795,131 @@ describe('空转的写当场拒', () => {
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.written).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* **空转写身后还有别的行动:无事发生,串不废**(2026-08-22,E04 活单熔断实测):
|
||||
* 模型每拍计划「写记账旗 + 发 S5 执行卡」两个动作的串 —— 旗的值没变,第 1 个动作
|
||||
* 被拒,而「被拒停在谁那儿」把整串弃权,排在后面的发卡从没执行过;连续五拍如此,
|
||||
* 熔断交人。提示词的 world-touch 事实行与拒回的 blockedOnWorld 指路**都在**,
|
||||
* 模型照样先写旗 —— 措辞已到头,结构治(设计律第六条):值没变的写什么也没改,
|
||||
* 后面的行动前提未变,不该陪葬。
|
||||
*/
|
||||
it('空转写身后还有行动:无事发生不废串 —— 排在后面的发卡照发', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
actions: [
|
||||
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
{ kind: 'human', itemId: 'GATED', card: { title: '执行回执工单', ask: '请执行并回填' }, workOrder: 'execution' },
|
||||
],
|
||||
writeNoop: true,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued.filter((c) => c.workOrder === 'execution')).toHaveLength(1);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
// 无事发生也要说出来 —— 模型下一拍得看见「值已在台账里」这个事实
|
||||
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('写了等于没写'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测:模型调
|
||||
* rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,下一拍补上项号
|
||||
* 原样重调 —— 那次拒回什么都没保护,白烧一拍。与写行动同款:挂不上不拦,
|
||||
* 留痕注明,记账挂项是尽力而为,不是门。
|
||||
*/
|
||||
it('调工具项号为空/挂不上:照调不拦 —— 授权面是工具白名单,不是计划项', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: { kind: 'tool', itemId: '', tool: 'crm.history-price-read', input: { pns: ['A'] } },
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
|
||||
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调赌变卦;
|
||||
* 出参每次原样摆在轨迹里它照调不误,26 次调用烧穿预算的前科在案,而 stalledTurns
|
||||
* 只数「行动被拒」,成功的重调没有任何兜底)。与「重发已答过的卡」同族的软硬两段:
|
||||
* 重调的头几次照调、出参标题携带点名警告(不算被拒,模型不撞墙);第 5 次identical
|
||||
* 才拒(真卡死了,熔断兜底要看得见)。写过/撤过之后世界变了,计数随写清零。
|
||||
*/
|
||||
it('同一份入参的重调:出参标题带点名警告 —— 不拒,但话要说到', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo, { kind: 'observation', title: '这一拍在往下办事' }, { ...echo }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.includes('第 3 次调用') && e.title.includes('别再调了'))).toBe(true);
|
||||
});
|
||||
|
||||
it('同一份入参连调第 5 次(期间没写过东西):当场拒 —— 真卡死了,熔断要看得见', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = () => ({
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
});
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo(), echo(), echo(), echo()],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toHaveLength(0);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
|
||||
});
|
||||
|
||||
it('两次识别之间写过台账:世界变了,重调照放行', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
action: CALL,
|
||||
trail: [echo, { ...echo }, { kind: 'action', title: '写草稿:d.o 1 行(1 行有变化)', detail: { ledger: 'd.o' } }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
});
|
||||
|
||||
it('入参不同的重调不算原地打转:照放行', async () => {
|
||||
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['B'] } };
|
||||
const echo = {
|
||||
kind: 'result',
|
||||
title: 'crm.history-price-read 返回',
|
||||
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
|
||||
};
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CALL, trail: [echo, { ...echo }] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.toolCalls).toContain('crm.history-price-read');
|
||||
});
|
||||
|
||||
/* 串尾/独苗的空转写仍当场拒 —— 熔断检测(stalledTurns 数「行动被拒」)靠它兜底 */
|
||||
it('空转写是独苗:照旧当场拒,熔断检测看得见', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE, writeNoop: true });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('宣告完成', () => {
|
||||
@@ -639,16 +954,111 @@ describe('宣告完成', () => {
|
||||
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
|
||||
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
|
||||
*/
|
||||
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
it('标了人工激活的项:已经真触达过世界,模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
// worldTouched 模拟「更早的拍已经发过卡 / 立过监听」——这里只测「不必再补一次 start」那半条
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, worldTouched: true } : i)) } as never,
|
||||
action: finish('GATED'),
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **触达世界的项,没真触达过就不许 finish**(2026-08-21,E04 实测逼出来的:
|
||||
* S5_execute_receipt 从没给人发过执行回执工单,模型直接 finish 掉了它,
|
||||
* 人根本没被问过完成状态,台账拿空回执重算了覆盖度)。`manualActivation`
|
||||
* 声明的正是「完成与否不该由数据形状推出来」——如果 finish 不查有没有真的
|
||||
* human/entry/decide/listen 之一发生过,这条声明形同虚设,模型可以随口
|
||||
* 编一个「完成」。
|
||||
*/
|
||||
it('标了人工激活的项:从没真触达过世界,finish 被拒——不许凭空宣布人已经办完了', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
|
||||
});
|
||||
|
||||
it('编出来的项收不了', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('我编的项') });
|
||||
expect((await h.runner.runTurn(TASK)).status).toBe('running');
|
||||
});
|
||||
|
||||
/*
|
||||
* **声明了 doneWhen 的项,算出来是假就不许 finish**(2026-08-22,E04 实测逼出来的:
|
||||
* S1 该写两行候选客户,模型只写成一行就直接 finish——doneWhen 明明为假,这一步照样
|
||||
* 被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,一路悄悄漏到 S5 提交闸才第一次
|
||||
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
|
||||
* 不是让模型自己判断,是让基座去核。
|
||||
*/
|
||||
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
|
||||
sfeelCondition: () => false,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('running');
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
|
||||
});
|
||||
|
||||
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
|
||||
sfeelCondition: () => true,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **同一串里先写后 finish,doneWhen 要按写完之后的草稿算**(2026-08-22,E04 实测:
|
||||
* S1 一拍连做「写候选行 + finish」——写已经落进草稿,finish 却按拍首快照判 doneWhen
|
||||
* 为假被拒;下一拍引擎又用同一份数据自动收步。同一份数据两个裁决,每单白吃一次被拒,
|
||||
* 而「先写后 finish 一拍连做」正是提示词鼓励的省拍打法,不该被快照误伤。
|
||||
*/
|
||||
it('同一串先写后 finish:doneWhen 按写完后的草稿算,不按拍首快照', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
actions: [
|
||||
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
|
||||
{ kind: 'finish', itemId: 'FREE', rationale: '行已写齐' },
|
||||
],
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'record_rows > 0' }],
|
||||
viewFn: (written) => ({ record_rows: written.length }),
|
||||
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
it('doneWhen 表达式算错了(抛错)—— 不拖垮这一拍,原样交给模型的 finish 判断', async () => {
|
||||
const h = harness({
|
||||
parsed: STAGE_MODEL,
|
||||
runtime: READY_RUNTIME2,
|
||||
action: finish('FREE'),
|
||||
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: '这不是一个合法表达式(((' }],
|
||||
sfeelCondition: () => {
|
||||
throw new Error('写坏了');
|
||||
},
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('没声明 doneWhen 的项:finish 不受这道新门槛影响,照旧收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('FREE'), sfeelCondition: () => false });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -873,6 +1283,37 @@ describe('画面不再静默丢帧', () => {
|
||||
expect(h.writesAtPush).toEqual([2]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一拍把上一步收了、下一步的卡也发了 —— 画面得跟着走到下一步**
|
||||
* (2026-08-23 E04 实测:S4 计划审批收掉、S5 执行卡当场发出,而工作区还摆着
|
||||
* 审批那份只读件。人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有,
|
||||
* 提交闸如实拦住,他无处可填 —— 墙拦住却不给路)。
|
||||
* 病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**。
|
||||
*/
|
||||
it('一串行动把案子推到下一步 → 推的是下一步的画面,不是拍首那一步的', async () => {
|
||||
const TWO: CaseModel = {
|
||||
name: '两步',
|
||||
autoComplete: true,
|
||||
items: [
|
||||
{ id: 'i1', name: '第一步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
{ id: 'i2', name: '第二步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const h = harness({
|
||||
parsed: TWO,
|
||||
runtime: { items: [{ id: 'i1', state: 'Active' }, { id: 'i2', state: 'Available' }] } as never,
|
||||
templates: ['step1-view', 'step2-view'],
|
||||
steps: [
|
||||
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
|
||||
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
|
||||
],
|
||||
actions: [{ kind: 'finish', itemId: 'i1' }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.pushed).toEqual(['step2-view']);
|
||||
});
|
||||
|
||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
|
||||
@@ -76,6 +76,17 @@ export type ItemRuntime = {
|
||||
readonly instance: number;
|
||||
/** 语义性前提待裁量评估的标记 */
|
||||
readonly awaitingJudgement?: boolean;
|
||||
/**
|
||||
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
|
||||
*
|
||||
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
|
||||
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
|
||||
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
|
||||
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
|
||||
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
|
||||
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
|
||||
*/
|
||||
readonly worldTouched?: boolean;
|
||||
};
|
||||
|
||||
export type CaseRuntime = {
|
||||
@@ -342,6 +353,11 @@ export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemSta
|
||||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
|
||||
}
|
||||
|
||||
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
|
||||
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
|
||||
}
|
||||
|
||||
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
|
||||
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
criterionKey,
|
||||
readingsOf,
|
||||
initialRuntime,
|
||||
markWorldTouched,
|
||||
navigate,
|
||||
occur,
|
||||
publishEnabled,
|
||||
@@ -139,6 +140,27 @@ function compact(value: unknown, limit: number = TRAIL_PAYLOAD_LIMIT): string {
|
||||
* 非法行动、每拍被拓扑拒回来,人在界面上只看到「进行中」一动不动 ——
|
||||
* 那不是「在办」,是卡死了。Harness 的活就是在这时候把它交回给人。
|
||||
*/
|
||||
/**
|
||||
* 工具入参的短哈希 —— 「同一份入参重调」检查的账。
|
||||
* 存整份入参不行(轨迹要进提示词,一份 contacts 数组就是几百字);djb2 足够认同一性,
|
||||
* 碰撞的代价只是一次多余的拦截提示,不是数据错误。
|
||||
*/
|
||||
/** 「验收被打回重做」那条痕的抬头 —— 写它与找它的是同一个常量,改一处两处都跟着改 */
|
||||
const REDO_MARK = '验收被打回重做';
|
||||
/**
|
||||
* 「打回之后没改就再提交」那次拒回里的记号句 —— 软硬两段靠它数第二次。
|
||||
* 认的是**句子**不是抬头:同一条拒回经两条路留痕,抬头各不相同
|
||||
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
||||
*/
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||
return `${text.length}:${(hash >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
export function stalledTurns(trail: ReadonlyArray<{ kind: string; title: string }>): number {
|
||||
let count = 0;
|
||||
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
||||
@@ -348,6 +370,13 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* **项号挂不上**(没给 / 编的一个不存在的号)—— 与「这一步已经办完了」是两件事:
|
||||
* 前者只是记账没对上(写、调工具、发卡都照办,留痕注明),后者是真墙(别往办完的事上派活)。
|
||||
* 分成两种错误型,调用方才好各按各的处置;混成一句,要么该拦的不拦,要么该放的不放。
|
||||
*/
|
||||
class ItemUnattachableError extends Error {}
|
||||
|
||||
/**
|
||||
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
|
||||
*
|
||||
@@ -372,7 +401,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
.map((i) => i.id);
|
||||
// 空字符串不是「不存在的项」,是**没给项号** —— 两件事说成一句,读的人只能猜
|
||||
const what = itemId.trim() === '' ? '没给计划项号' : `计划项不存在:${itemId}`;
|
||||
throw new Error(`${what}(可选:${legal.join('、') || '无'})`);
|
||||
throw new ItemUnattachableError(`${what}(可选:${legal.join('、') || '无'})`);
|
||||
}
|
||||
/*
|
||||
* 父阶段没开着就不许在里面干活 —— 不然 CMMN 的阶段门是装饰。
|
||||
@@ -498,6 +527,26 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
taskId: TaskId,
|
||||
rawAction: AgentAction,
|
||||
runtime: CaseRuntime,
|
||||
/**
|
||||
* **这一项声明的 doneWhen,现在算出来是真是假**(undefined = 没声明,或算不出来)。
|
||||
* 只有 `finish` 用得上——`doneWhen` 从前只管「引擎自己收要不要收」,没管过
|
||||
* 「模型自己 finish 让不让」:一个没有 manualActivation 的普通步骤,模型随时能
|
||||
* 显式 finish 掉它,doneWhen 是真是假完全不影响这条路(2026-08-22 实测:
|
||||
* S1 该写两行候选客户,模型只写成一行就直接 finish,doneWhen 明明为假,
|
||||
* 这一步照样被记成「完成」)。**声明了 doneWhen 就是说「这件事完没完是可以算出来的」**——
|
||||
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
|
||||
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
|
||||
*/
|
||||
doneWhenOf?: (itemId: string) => boolean | undefined,
|
||||
/**
|
||||
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
|
||||
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
|
||||
* 模型每拍计划「写记账旗 + 发执行卡」,旗的值没变,排在后面的发卡从没执行过,
|
||||
* 连拒五拍熔断。值没变的写**什么也没改**,后面行动的前提未变,不该陪葬:
|
||||
* 有后续就降级成「无事发生」放行整串;没有后续(串尾/独苗)仍拒 ——
|
||||
* 熔断检测(stalledTurns 数「行动被拒」)靠它兜底,纯空转的单不能悄无声息。
|
||||
*/
|
||||
hasFollowUp = false,
|
||||
): Promise<{
|
||||
runtime: CaseRuntime;
|
||||
status: TurnOutcome['status'];
|
||||
@@ -615,10 +664,56 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
// 先验迁移再动手:不合法的行动不该在世界上留下痕迹(登记意向、调工具都算)
|
||||
const { parsed: toolModel } = await loaded(taskId);
|
||||
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
|
||||
const working = readyToWork(toolModel, runtime, action.itemId);
|
||||
/*
|
||||
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
|
||||
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调
|
||||
* 赌变卦;出参每次原样摆在轨迹里,它照调不误。26 次调用烧穿 500k 预算的前科在案,
|
||||
* 而 stalledTurns 只数「行动被拒」,成功的重调它看不见 —— 没有任何兜底)。
|
||||
* 头两次照调;期间**没有任何写/撤动作**(世界没变,读出来的只会一样)的第 3 次
|
||||
* identical 调用当场拒 —— 拒回是模型反应最好的通道,也归熔断兜底管。
|
||||
* 写过之后计数清零:世界变了,重调是合法的。
|
||||
*/
|
||||
let sameCalls = 0;
|
||||
if (!(await deps.tools.isWriteEffect(action.tool))) {
|
||||
const hash = toolInputHash(action.input);
|
||||
const recent = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
for (let i = recent.length - 1; i >= 0; i -= 1) {
|
||||
const entry = recent[i]!;
|
||||
if (entry.kind === 'action' && typeof entry.detail?.['ledger'] === 'string') break; // 写/撤过,世界变了
|
||||
if (entry.kind === 'result' && entry.title.startsWith(`${action.tool} 返回`) && entry.detail?.['inputHash'] === hash) {
|
||||
sameCalls += 1;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 与「重发已答过的卡」同族的软硬两段:重调的头几次**照调**(它是本地纯函数,
|
||||
* 调用本身不贵,贵的是拍)但出参标题携带点名警告 —— 不算被拒,模型不撞墙;
|
||||
* 到第 5 次identical 才拒(那时是真卡死了,熔断兜底要看得见它)。
|
||||
*/
|
||||
if (sameCalls >= 4) {
|
||||
throw new Error(
|
||||
`${action.tool} 用同一份入参已经调过 ${sameCalls} 次,出参就在你的轨迹里,而这期间台账一个字没动 —— ` +
|
||||
'它是按入参算数的工具,同样的输入永远同样的结论,再调一遍不会变卦。' +
|
||||
'算出来的数与你预想的不一样,先检查入参是不是漏了字段;确认无误就**按这个数往下办**' +
|
||||
'(该写的表写上、该收的步收掉),别再调了。',
|
||||
);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测):
|
||||
* 模型调 rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,
|
||||
* 下一拍补上项号原样重调 —— 那次拒回什么都没保护,白烧一拍。
|
||||
* 与写行动同款(「写的授权面是台账声明」):itemId 只是记账,
|
||||
* 挂不上(空串/项不存在/已收掉)照调不拦,留痕注明;记账挂项尽力而为。
|
||||
*/
|
||||
let working = runtime;
|
||||
let unattached: string | undefined;
|
||||
try {
|
||||
// 站上场即可 —— **不收场**:一件事常要几个动作才交得出成果,收了就再也动不了它
|
||||
working = readyToWork(toolModel, runtime, action.itemId);
|
||||
} catch (error) {
|
||||
unattached = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
// 写类工具登记为动作意向 —— 触达世界的动作集中在放行时落地
|
||||
if (await deps.tools.isWriteEffect(action.tool)) {
|
||||
await deps.tools.registerIntent({
|
||||
@@ -651,12 +746,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${action.tool} ${result.ok ? '返回' : '失败'}`,
|
||||
detail: { output: result.output, error: result.error?.message },
|
||||
title:
|
||||
`${action.tool} ${result.ok ? '返回' : '失败'}` +
|
||||
(unattached === undefined ? '' : ` —— 未挂到计划项(${unattached})`) +
|
||||
// 重调的警告写在出参标题上 —— 它随轨迹进下一拍的上下文,正是模型看的地方
|
||||
(sameCalls === 0 || !result.ok
|
||||
? ''
|
||||
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
|
||||
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
|
||||
detail: { output: result.output, error: result.error?.message, inputHash: toolInputHash(action.input) },
|
||||
});
|
||||
return {
|
||||
// 失败才动状态(Failed 是拓扑里的一档);成了就留在 Active 等 Agent 宣告完成
|
||||
runtime: result.ok ? working : transitionItem(working, action.itemId, 'Failed'),
|
||||
// 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
|
||||
runtime: result.ok || unattached !== undefined ? working : transitionItem(working, action.itemId, 'Failed'),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
@@ -676,7 +778,20 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* T_release —— 每一拍的补写都被「要先 start」拒回来,连拒五拍熔断交人,
|
||||
* 而那几行数据本身合法合规。活没干完却没有项可挂,是记账问题,不是授权问题。
|
||||
*/
|
||||
if (action.rows.length === 0) throw new Error('写台账要给行:rows 为空,这一拍什么也没发生');
|
||||
/*
|
||||
* **空 rows 的拒回要点名地址,不能只说「什么也没发生」**(2026-08-21 实测:
|
||||
* E04 一拍连发三个写,前两个成了,第三个 crm_coverage.ledger_update 带着空
|
||||
* rows 被拒——下一拍模型没有回头补那一张,反倒把前两张已经成功的表原样
|
||||
* 重写,连拒五拍熔断)。这张表这一拍**一行都没写**,不是「写错了要改」,
|
||||
* 是「压根没写」——把地址钉在拒回里,下一拍才有目标可对,不会去改别的表。
|
||||
*/
|
||||
if (action.rows.length === 0) {
|
||||
throw new Error(
|
||||
`写台账要给行:${action.address} 这一下 rows 是空的,这一拍什么也没发生 —— ` +
|
||||
`这张表这一拍一行都没写(不是内容错了要改,是压根没写),下一拍别去动别的表` +
|
||||
'(尤其别把已经写成功的表重写一遍,那只会被当空转再拒一次),回来把这张表真正的行写上。',
|
||||
);
|
||||
}
|
||||
const at = action.address.indexOf('.');
|
||||
if (at <= 0 || at === action.address.length - 1) {
|
||||
throw new Error(`台账地址要写成「域.对象」:收到 ${action.address || '(空)'}`);
|
||||
@@ -719,11 +834,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
|
||||
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
|
||||
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
|
||||
*
|
||||
* **「该 finish 的项」这半句在一种局面下会把模型指回死路**(2026-08-21
|
||||
* 实测:E04 finish 被 worldTouched 那道新门拒了之后,模型没去发卡,
|
||||
* 反倒把同一行 verdict 原样重写了五拍,连拒到熔断)——它其实卡在
|
||||
* 「有一项标了人工激活,却还没真给人发过卡」,这句泛泛的指路对它不管用。
|
||||
* 查一眼当下有没有这样的项,点名指过去:该发的是卡,不是再写一遍这张表。
|
||||
*/
|
||||
const blockedOnWorld = model.items.find((i) => {
|
||||
if (i.manualActivation !== true) return false;
|
||||
const state = runtime.items.find((r) => r.id === i.id);
|
||||
return (state?.state === 'Enabled' || state?.state === 'Active') && state.worldTouched !== true;
|
||||
});
|
||||
/*
|
||||
* **身后还有行动就不拒:无事发生,串照走**(2026-08-22,E04 活单熔断实测)。
|
||||
* 模型每拍计划「写记账旗 + 发 S5 执行卡」—— 旗的值没变,这里一拒整串弃权,
|
||||
* 排在后面的发卡从没执行过;提示词的 world-touch 事实行与下面 blockedOnWorld
|
||||
* 的指路**都在**,它照样先写旗:措辞已到头,结构治。值没变的写什么也没改,
|
||||
* 后面行动的前提未变,不该陪葬。留痕照说(模型下一拍要看见「值已在台账里」),
|
||||
* 局面原样交回 —— 无事发生不站上场(与 start 已开项、重发已答卡同族)。
|
||||
*/
|
||||
if (hasFollowUp) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— 无事发生,这一串后面的行动照走`,
|
||||
detail: { internal: true, ledger: action.address, itemId: action.itemId },
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
throw new Error(
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
|
||||
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次` +
|
||||
(blockedOnWorld === undefined
|
||||
? ''
|
||||
: ` —— 这一单当下卡在 ${blockedOnWorld.id}(标了人工激活,这一单里还没真给它发过卡、` +
|
||||
'也没为它立过监听):别再往这张表上写了,去照说明书发那一张卡(human/entry/decide)' +
|
||||
'或立一次监听(listen),这才是当下唯一能往下走的路。'),
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -875,7 +1023,30 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
}
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
/*
|
||||
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
|
||||
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 itemId 就是空串 ——
|
||||
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下。
|
||||
* **最后的活路自己被砌了墙,这一单就真没路了。**
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
|
||||
* 挂不上照发、留痕注明。人工激活那道 worldTouched 门不受影响 ——
|
||||
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
|
||||
*/
|
||||
let advanced = runtime;
|
||||
let cardUnattached: string | undefined;
|
||||
try {
|
||||
advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
} catch (error) {
|
||||
// 「这一步已经办完/停用」是真墙,原样抛回去(拒回话术会把模型指向当下那一步)
|
||||
if (!(error instanceof ItemUnattachableError)) throw error;
|
||||
cardUnattached = error.message;
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `发卡未挂到计划项(${cardUnattached}) —— 卡照发,记账挂不上不拦人`,
|
||||
detail: { internal: true, card: action.card },
|
||||
});
|
||||
}
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
@@ -889,7 +1060,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
// 挂不上就没有项可标 —— worldTouched 只记在真挂上的那一项上,那道门因此仍然诚实
|
||||
return {
|
||||
runtime: cardUnattached === undefined ? markWorldTouched(advanced, action.itemId) : advanced,
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
|
||||
case 'ask': {
|
||||
@@ -932,7 +1107,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: entryCard.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: entryReady, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(entryReady, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'decide': {
|
||||
@@ -992,10 +1167,31 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(advanced, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'listen': {
|
||||
/*
|
||||
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23 加,
|
||||
* 柴浩实测逼出来的:E04 一单卡在依赖断点 —— 模型没写那面记账旗、下一步的哨兵
|
||||
* 永不成立,它于是临场「立个 data 监听等回执」;而 E04 一个监听都没声明、
|
||||
* 这一单也不会有任何 data 事件进来,单子就此挂在 awaiting-event 15 分钟一动不动)。
|
||||
*
|
||||
* **立了没人叫醒的监听不是等待,是失联** —— 与「写的授权面是台账声明」
|
||||
* 「调工具的授权面是白名单」同一条纪律。拒回同时把两条真路指出来:
|
||||
* 要等人办事就发执行工单;卡在断点就回去补那个没写的数。
|
||||
*/
|
||||
const declared = await deps.listeners.declared(taskId);
|
||||
if (!declared.some((d) => d.eventType === action.eventType)) {
|
||||
const kinds = declared.map((d) => d.eventType).join('、');
|
||||
throw new Error(
|
||||
`这个活动没声明过 ${action.eventType} 类监听(声明过的:${kinds || '一个都没有'})—— ` +
|
||||
'立了也没人会来叫醒它,这一单会一直挂着。要等人去办事就**发执行工单**(human,' +
|
||||
'人在工作区的件上把回音填回来);当下要是没有项可干(依赖断点),' +
|
||||
'多半是某个入场前提的数还没写(看「干完的判据」那几行读数,常见的是某面记账旗漏了),' +
|
||||
'回去把那一笔补上,门自己就开了。',
|
||||
);
|
||||
}
|
||||
// AI 不能调用世界:对世界只有两种合法姿势 —— 触达它(动作)与等它(监听)
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
@@ -1009,7 +1205,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
title: `立监听:${action.eventType}(时限 ${action.deadline})`,
|
||||
detail: action.keyFilter,
|
||||
});
|
||||
return { runtime, status: 'awaiting' };
|
||||
return { runtime: markWorldTouched(runtime, action.itemId), status: 'awaiting' };
|
||||
}
|
||||
|
||||
case 'asked': {
|
||||
@@ -1057,6 +1253,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
});
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
/*
|
||||
* **触达世界的项,没真触达过就不许 finish**(2026-08-21 用户实测逼出来的:
|
||||
* E04 的 S5_execute_receipt 从未派发执行回执工单,模型一拍之内就把它
|
||||
* finish 掉了,人根本没被问过完成状态/结果摘要,收口那一步拿空回执重算了
|
||||
* 覆盖度)。`manualActivation = true` 声明的正是「这一项完成与否**不该由
|
||||
* 数据形状推出来**」(AM 12.2 原理一)——可 `finish` 从前只查生命周期迁移
|
||||
* 合不合法,从没查过「人真的被问过吗」。凭空写的完成不是完成,是编造。
|
||||
*
|
||||
* 门槛只认 `worldTouched`(见 lifecycle.ts):human / entry / decide / listen
|
||||
* 四个「触达世界」的动作之一真的针对这一项执行过,才算数——不管是这一拍刚发的,
|
||||
* 还是更早的拍留下的(reject 之后重发也算,标记不会中途被清)。
|
||||
*/
|
||||
const item = finishModel.items.find((i) => i.id === action.itemId);
|
||||
if (item?.manualActivation === true && runtime.items.find((i) => i.id === action.itemId)?.worldTouched !== true) {
|
||||
throw new Error(
|
||||
`${action.itemId} 标了人工激活(触达世界),但这一单里从没给它发过人类工具的卡、` +
|
||||
'也没为它立过监听 —— finish 不能凭空宣布「人已经办完了」。先照说明书发一张' +
|
||||
'卡(human/entry/decide)或立一次监听(listen),真收到回音再 finish;' +
|
||||
'如果这一步其实不需要人(说明书写错了),那是活动包的声明问题,不是这一拍能绕过去的。',
|
||||
);
|
||||
}
|
||||
/*
|
||||
* **声明了 doneWhen,就不许模型自己一句话绕过去**(2026-08-22 实测:E04 S1
|
||||
* 该写两行候选客户,模型只写成了一行就直接 finish——doneWhen 明明为假,
|
||||
* 这一步照样被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,
|
||||
* 一路悄悄漏到 S5 提交闸才第一次被拦下)。`doneWhen` 是「这件事完没完可以
|
||||
* 算出来」的声明,算出来是假,finish 就该被拒——不是让模型自己判断,
|
||||
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
|
||||
*/
|
||||
const doneWhenNow = doneWhenOf?.(action.itemId);
|
||||
if (doneWhenNow === false) {
|
||||
throw new Error(
|
||||
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
|
||||
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
|
||||
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
|
||||
'基座会自己收,不必你再 finish。',
|
||||
);
|
||||
}
|
||||
const ready = readyToWork(finishModel, runtime, action.itemId);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
@@ -1099,6 +1333,34 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/** 验收报告上让人当场改的那几个值(带 AI 拟值与落回地址)—— 见 submit 行动的注释 */
|
||||
fields?: ReadonlyArray<{ key: string; label: string; ai?: string; ledger?: string }>,
|
||||
): Promise<{ runtime: CaseRuntime; status: TurnOutcome['status'] }> {
|
||||
/*
|
||||
* **人打回了这一份成果,改都没改就不许再递上来**(2026-08-22,柴浩实测:点「重做」
|
||||
* 写了「重做一下」,6 秒后同一份成果原样又递回 —— 三条裁量判据重判一遍、台账一个字没动。
|
||||
* 人看见的是「我说了重做,它当没听见」)。
|
||||
*
|
||||
* 根子在局面:打回那一刻案例模型的项全是 Completed,模型除了重新 submit 无事可做,
|
||||
* 拒回文案与说明书都劝不动它(**措辞治不了的结构治**)。所以由提交这道唯一的出口
|
||||
* 自己看一眼:打回之后台账动过没有(写 / 撤都算)——没动就不放行。
|
||||
*
|
||||
* 软硬两段(与「重发已答过的卡」「同一份入参重调」同族):头一次拒,把路指明;
|
||||
* 已经因此拒过一次还坚持原样提交就放行 —— 人还能再打回,墙要配路,不把单子锁死。
|
||||
*/
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
let lastRedo = -1;
|
||||
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
||||
if (lastRedo >= 0) {
|
||||
const after = window.slice(lastRedo + 1);
|
||||
const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string');
|
||||
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
||||
if (!revised && !refusedBefore) {
|
||||
throw new Error(
|
||||
`人打回了这一份成果,而${REFUSE_MARK} —— 原样再递上去,` +
|
||||
'他看见的还是刚被他打回的那一份。先按他的意见改台账(write 覆盖写 / drop 撤行都使得),' +
|
||||
'改完再提交;他没写具体意见的话,自己把成果复核一遍、至少改对一处,' +
|
||||
'或者发一张卡问清楚他要改什么,别原样重提。',
|
||||
);
|
||||
}
|
||||
}
|
||||
const context = await deps.tasks.of(taskId);
|
||||
/*
|
||||
* **发验收卡之前先把放行那一页摆好**(2026-07-31)。
|
||||
@@ -1191,22 +1453,43 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const pending = (await loaded(taskId)).pending;
|
||||
|
||||
/*
|
||||
* 在途验收先于一切:裁决到了按它定终局(released 与否都是终局,不再问 Agent);
|
||||
* 没到就继续等 —— 不重发卡、不空跑模型。
|
||||
* 在途验收先于一切:没裁就继续等(不重发卡、不空跑模型);裁了按 verdict 分岔:
|
||||
* · `pass` → 终局(released),不再问 Agent;
|
||||
* · **非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:从前这里也走终局分支,
|
||||
* task 层把「finished 未放行」重新挂成 awaiting-human —— 人已经说了「重做」,
|
||||
* 却再没有任何事件会叫醒它,一单就此卡死)。重做 = 带着意见退回去再干一遍:
|
||||
* 意见用人话进轨迹、在途验收清掉,**这一拍照常往下问模型** ——
|
||||
* 模型修订台账草稿后重新 submit,新发的验收卡才是下一次裁决的对象。
|
||||
*/
|
||||
let redone = false;
|
||||
if (pending !== undefined) {
|
||||
if (pending.verdict === undefined) {
|
||||
return { runtime, enabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
}
|
||||
if (pending.verdict === 'pass') {
|
||||
await deps.state.save(taskId, model, parsed, runtime, null);
|
||||
return {
|
||||
runtime,
|
||||
enabled,
|
||||
navigation: { kind: 'converged' },
|
||||
status: 'finished',
|
||||
released: true,
|
||||
...(pending.selectedPorts === undefined ? {} : { selectedPorts: pending.selectedPorts }),
|
||||
};
|
||||
}
|
||||
redone = true;
|
||||
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 }),
|
||||
};
|
||||
const comment = pending.comment?.trim() ?? '';
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${REDO_MARK}${comment === '' ? '(人没写具体意见 —— 自己把成果复核一遍,至少一处实质性修订)' : `:${comment}`}`,
|
||||
detail: {
|
||||
hint:
|
||||
'按意见修订台账草稿(write 覆盖写 / drop 撤行都使得),改完重新提交放行(submit)——' +
|
||||
'新发的验收卡才是下一次裁决的对象;别原样重提,那只会再被打回一次。',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1215,8 +1498,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 提交要过**落地预检**那道门,预检不过会抛(必交付物缺、意向不可落地、他单改了同一字段)。
|
||||
* 抛了不能让回合崩:推进器会一遍遍重来,每遍再撞一次同一道门。
|
||||
* 正确做法与「行动被拒」一致 —— 拒绝 + 留痕 + 把理由喂回下一拍,让 Agent 自己去补。
|
||||
*
|
||||
* **刚被打回重做的那一拍不走这条快道**:判齐恒真的活动会在意见没人读的情况下
|
||||
* 立刻再发一张一模一样的验收卡 —— 人看见的是「我说了重做,它原样又递上来了」。
|
||||
* 这一拍先交给模型(意见就在轨迹里),它改完自己 submit。
|
||||
*/
|
||||
if (autoCompleteReady(parsed, runtime)) {
|
||||
if (autoCompleteReady(parsed, runtime) && !redone) {
|
||||
try {
|
||||
const raised = await raiseAcceptance(taskId, runtime, '必做项全部完成,无进行中项');
|
||||
return { runtime: raised.runtime, enabled, navigation: { kind: 'converged' }, status: raised.status };
|
||||
@@ -1431,11 +1718,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const current =
|
||||
liveStep ??
|
||||
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
||||
/*
|
||||
* **触达世界的项,「有没有真的问过人」是一个数,不是一句要模型自己去轨迹里找的话**
|
||||
* (2026-08-21 用户实测逼出来的:E04 S5_execute_receipt 的 guide 已经写了
|
||||
* 「动手之前先在轨迹里找 [card-reply]」,finish 被拒的理由也点名了同一件事,
|
||||
* 模型两次都视而不见,一路把同一行 verdict 重写到连拒五拍熔断)。
|
||||
* 与「干完的判据」同一条治法:**能算的不问人、也不指望模型自己算**——
|
||||
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
||||
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
||||
*/
|
||||
const currentTouch = ((): string => {
|
||||
if (current === undefined) return '';
|
||||
const declared = parsed.items.find((i) => i.id === current.item);
|
||||
if (declared?.manualActivation !== true) return '';
|
||||
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
return touched
|
||||
? '这一步标了人工激活(触达世界):这一单里已经真的给它发过卡或立过监听——人已经被问过了,' +
|
||||
'照下面「人交回之后」那一段办。'
|
||||
: '这一步标了人工激活(触达世界):这一单里从没给它发过卡(human/entry/decide)、' +
|
||||
'也没为它立过监听(listen)——**没有别的路,唯一合法的动作是先发那一张卡或立那次监听**,' +
|
||||
'写台账、finish 都还轮不到,写了也会被拒。';
|
||||
})();
|
||||
const staged =
|
||||
current === undefined
|
||||
? narrative
|
||||
: [
|
||||
`【当前这一步 · ${current.item}】${current.intent}`,
|
||||
currentTouch,
|
||||
current.guide,
|
||||
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
|
||||
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
|
||||
@@ -1630,6 +1939,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
let saved = false;
|
||||
let framed = false;
|
||||
let lastAction: AgentAction | undefined;
|
||||
/*
|
||||
* **finish 的 doneWhen 要按「此刻」的草稿算,不是拍首快照**(2026-08-22,E04 实测):
|
||||
* 「同一串先写后 finish 一拍连做」是提示词鼓励的省拍打法,而 doneWhen 若按拍首的
|
||||
* `scope` 求值,刚落进草稿的那几行看不见 —— finish 被「算出来是假」误拒,
|
||||
* 下一拍引擎又用同一份数据自动收步:同一份数据两个裁决,每单白吃一次被拒。
|
||||
* 写/撤落笔后标脏,轮到 finish 时才重取一次任务视图(不是每写一笔就取)。
|
||||
*/
|
||||
let liveScope = scope;
|
||||
let scopeStale = false;
|
||||
|
||||
for (let index = 0; index < planned.length; index += 1) {
|
||||
const action = planned[index]!;
|
||||
@@ -1646,7 +1964,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
if (action.kind === 'start' && parsed.items.find((i) => i.id === action.itemId) === undefined) {
|
||||
throw new ForbiddenTerminalError('owner');
|
||||
}
|
||||
applied = await applyAction(taskId, action, currentRuntime);
|
||||
if (action.kind === 'finish' && scopeStale) {
|
||||
liveScope = await deps.data.view(taskId);
|
||||
scopeStale = false;
|
||||
}
|
||||
applied = await applyAction(
|
||||
taskId,
|
||||
action,
|
||||
currentRuntime,
|
||||
(itemId) => {
|
||||
const step = stepOf(itemId);
|
||||
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
|
||||
try {
|
||||
return deps.sfeel.condition(step.doneWhen, liveScope);
|
||||
} catch {
|
||||
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
index < planned.length - 1,
|
||||
);
|
||||
if (action.kind === 'write' || action.kind === 'drop') scopeStale = true;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
await deps.memory.append({
|
||||
@@ -1698,10 +2036,36 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 兜一张 `case-overview`(`showView` 再退到它声明的第一张)。
|
||||
* 写了剧本却挑不出 —— 活步都收完了 —— 就一帧不推。
|
||||
*/
|
||||
const frame = wanted !== undefined && wanted !== '' ? wanted : steps.length === 0 ? 'case-overview' : undefined;
|
||||
/*
|
||||
* **挑画面要按「这一拍办完之后」在办的那一步**(2026-08-23,E04 S4→S5 实测)。
|
||||
*
|
||||
* `liveStep` 是**拍首**算的,而一串行动常常把案子往前推一步:那一拍收掉 S4
|
||||
* (经营计划审批)、当场发出 S5 的执行卡,画面却仍按 S4 推 —— 人打开工作台,
|
||||
* 卡上写着「把回执填到件上」,屏上摆的是审批那份只读件,**一个可填的格子都没有**;
|
||||
* 提交闸如实拦住「缺完成状态、结果摘要、关键人变动」,而他无处可填。
|
||||
* 这是设计律第一条「墙要配路」的又一次现形,病根与 `liveScope` 那条同源:
|
||||
* **拿拍首的局面去决定拍尾的事**。推之前按当下的局面重算一次。
|
||||
*
|
||||
* 只在真的算出了一步时才顶掉 `wanted` —— 活步都收完那种情形照旧走老路
|
||||
* (`framed` 或一帧不推),免得把人刚签过的放行页冲掉。
|
||||
*/
|
||||
if (scopeStale) {
|
||||
liveScope = await deps.data.view(taskId);
|
||||
scopeStale = false;
|
||||
}
|
||||
const settledNow = new Set(
|
||||
currentRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||
);
|
||||
const enabledNow = publishEnabled(parsed, currentRuntime, liveScope, changedRefs(liveScope), deps.sfeel.condition, []);
|
||||
const stepNow = [...enabledNow.inProgress, ...enabledNow.entries]
|
||||
.filter((e) => !settledNow.has(e.id))
|
||||
.map((e) => stepOf(e.id))
|
||||
.find((x) => x !== undefined);
|
||||
const wantedNow = stepNow?.view !== undefined && stepNow.view !== '' ? stepNow.view : wanted;
|
||||
const frame = wantedNow !== undefined && wantedNow !== '' ? wantedNow : steps.length === 0 ? 'case-overview' : undefined;
|
||||
if (!framed && frame !== undefined && frame !== '') {
|
||||
try {
|
||||
const shown = await showView(taskId, frame, currentRuntime, liveStep?.view);
|
||||
const shown = await showView(taskId, frame, currentRuntime, stepNow?.view ?? liveStep?.view);
|
||||
if (!shown.pushed) {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
@@ -1742,9 +2106,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
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'];
|
||||
// 打回重做时人写的意见也要落进信封 —— 重做那一拍模型看的就是它
|
||||
const comment = event.payload['comment'];
|
||||
nextPending = {
|
||||
...pending,
|
||||
verdict,
|
||||
...(typeof comment === 'string' && comment.trim() !== '' ? { comment: comment.trim() } : {}),
|
||||
...(typeof ports === 'object' && ports !== null ? { selectedPorts: ports as Record<string, string> } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,11 +130,18 @@ export type ToolGateway = {
|
||||
|
||||
/**
|
||||
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
|
||||
* 判齐重入不再发第二张卡;恢复的那一拍据 verdict 定终局(released 与否)。
|
||||
* 判齐重入不再发第二张卡。恢复的那一拍按 verdict 分岔:
|
||||
* `pass` 才是终局(released);**非 pass = 打回重做,不是终局** ——
|
||||
* 意见(comment)用人话进轨迹、在途验收清掉,同一拍照常问模型,
|
||||
* 模型改完重新提交,新发的验收卡才是下一次裁决的对象(2026-08-22,
|
||||
* 从前非 pass 只把任务重新挂成 awaiting-human,人说了「重做」却再没有
|
||||
* 任何事件叫醒它,一单就此卡死 —— 柴浩实测)。
|
||||
*/
|
||||
export type PendingAcceptance = {
|
||||
readonly acceptanceCardId: string;
|
||||
readonly verdict?: 'pass' | 'reject';
|
||||
/** 打回重做时人写的意见 —— 空着就是点了固定按钮没写话 */
|
||||
readonly comment?: string;
|
||||
readonly selectedPorts?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -209,6 +216,15 @@ export type ListenerRegistrar = {
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* **这个活动声明过哪几种监听** —— 立监听的授权面(2026-08-23 加)。
|
||||
*
|
||||
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:没声明过的监听立不得。
|
||||
* 起因是实测的一次永久挂起(E04):模型在依赖断点上临场立了个 data 监听等回执,
|
||||
* 而那个活动 `declarations.listeners` 是空的、这一单也不会有任何 data 事件进来 ——
|
||||
* 单子就此挂在 `awaiting-event` 一动不动。**立了没人叫醒的监听不是等待,是失联。**
|
||||
*/
|
||||
declared(taskId: TaskId): Promise<ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user