脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙

凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 09:31:35 +08:00
co-authored by Claude Opus 5
parent e235207e4a
commit df18882322
16 changed files with 490 additions and 43 deletions
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } 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';
@@ -992,7 +992,7 @@ describe('宣告完成', () => {
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
* 不是让模型自己判断,是让基座去核。
*/
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
@@ -1005,6 +1005,25 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
});
/*
* **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
* 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
* 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
*/
it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', 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' }],
sfeelCondition: () => false,
trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
});
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
const h = harness({
parsed: STAGE_MODEL,
@@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => {
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
expect(line).toContain('人已经答过了');
expect(line).toContain('他选的是「approve」');
expect(line).toContain('finish');
});
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
expect(line).toContain('空答复 = 照原样确认');
expect(line).not.toContain('他选的是');
});
it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
const line = answeredCardFact([
sent('第一问'),
{ kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
sent('另一件完全不同的事'),
]);
expect(line).toBe('');
});
it('一张卡都没答过 → 空', () => {
expect(answeredCardFact([sent('还没答的卡')])).toBe('');
expect(answeredCardFact([])).toBe('');
});
});
describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
expect(why).toContain('缺的不是行也不是列,是一次工具调用');
expect(why).toContain('tool_w05_recommend_calls');
expect(why).toContain('tool_w05_recommend_calls=0');
});
it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
expect(why).not.toContain('缺的不是行也不是列');
expect(why).toContain('rows=9');
});
it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
expect(why).toContain('a_rows=3');
expect(why).toContain('b_rows=4');
expect(why).not.toContain('工具调用');
});
});
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
@@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
});
it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
kind: 'echo',
answered: '这一问',
});
expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
@@ -396,7 +396,10 @@ export function criterionKey(raw: string): string {
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
*/
/**
* **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
*
* 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
* 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
* `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
* 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
* finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
* **指向两扇打不开的门,比不指还糟。**
*
* 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
* 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
*/
export function unmetWhy(doneWhen: string, scope: Readonly<Record<string, unknown>>): string {
const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
if (idle.length === 0) return head;
return (
`${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
'说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
'别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
);
}
export function sameQuestion(a: string, b: string): boolean {
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
return short.length >= 6 && long.startsWith(short);
}
/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
export function earlyFinishTitleOf(itemId: string): string {
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
}
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
asked: string,
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const replies = window.filter((t) => t.kind === 'card-reply');
const replied = new Set(replies.map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
/*
* **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
* 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
* 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
* 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
* 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
*/
const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
}
/**
* **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
*
* 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
* 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
* 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
* 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
*
* 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
* **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
*
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
*/
export function answeredCardFact(
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
): string {
let at = -1;
for (let i = window.length - 1; i >= 0; i -= 1) {
if (window[i]?.kind === 'card-reply') {
at = i;
break;
}
}
const reply = at < 0 ? undefined : window[at];
if (reply === undefined) return '';
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
if (newer) return '';
const value = reply.detail?.['value'];
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
return chose === undefined
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
}
/**
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
*/
doneWhenOf?: (itemId: string) => boolean | undefined,
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
/**
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
(verdict.chose === undefined
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
: `**他选的是「${verdict.chose}」** —— `) +
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: echoTitle,
detail: {
internal: true,
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
note:
verdict.chose === undefined
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
},
});
// 什么也没做,局面原样交回
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
*/
const doneWhenNow = doneWhenOf?.(action.itemId);
if (doneWhenNow === false) {
throw new Error(
if (doneWhenNow?.ok === false) {
const say =
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
'基座会自己收,不必你再 finish。',
);
'不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
doneWhenNow.why;
/*
* **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
* 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
* 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
* 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
* 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
* 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
* 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
*/
const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
(t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
).length;
if (seen === 0) {
await deps.memory.append({
taskId,
kind: 'observation',
title: earlyFinishTitleOf(action.itemId),
detail: { internal: true, note: say },
});
return { runtime, status: 'running' };
}
throw new Error(say);
}
const ready = readyToWork(finishModel, runtime, action.itemId);
await deps.memory.append({
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
: [
`【当前这一步 · ${current.item}】${current.intent}`,
currentTouch,
answeredCardFact(trail),
current.guide,
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const step = stepOf(itemId);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
try {
return deps.sfeel.condition(step.doneWhen, liveScope);
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
} catch {
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
return undefined;