Archived
E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
+194
-7
@@ -339,9 +339,62 @@ const SUITES = {
|
||||
{
|
||||
// 样例 0 = 满载的那条(测试台落地即预填的也是它)
|
||||
sample: 0,
|
||||
name: '事件驱动 · 重大商机点名 CU-0142 · 计划一次通过',
|
||||
decide: 'approve',
|
||||
name: '事件驱动 · 重大商机点名 CU-0142 · 人在确认卡上加一家 · 点名驳回后通过',
|
||||
/*
|
||||
* 第一张审批卡以**点名意见**驳回(复刻 2026-08-24 柴浩实测:「测试111建议动作
|
||||
* 改成拜访CTO」被改到了 Itelma 头上)—— 意见点的是人加的那家客户,断言改动
|
||||
* 落在**被点名那一行**(expectRevised),不是落到别家。
|
||||
*/
|
||||
decideSeq: ['custom:端到端新增客户的建议动作改成约见CTO并建立联系', 'approve'],
|
||||
expectDecision: true,
|
||||
expectPort: 'coverage-map',
|
||||
expectRevised: { address: 'crm_coverage.plan', where: 'customer_code', equals: 'CU-E2E-ADD', column: 'action', contains: 'CTO' },
|
||||
/*
|
||||
* 查无联系人的新客户 → S3 先发数据录入卡收第一位关键人(2026-08-24 用户裁定:
|
||||
* 计划里不许出现占位人名 —— 从前那两行「待识别」是模型把占位句式泛化出来的)。
|
||||
*/
|
||||
entryValues: { name: '测试CTO王工', title: 'CTO', decision_node: 'bt-02' },
|
||||
forbidInPlan: ['待识别', '-00'],
|
||||
/*
|
||||
* 台账更新确认那一屏补录联系人(2026-08-24 用户定):编码留空 —— 自增列不问人,
|
||||
* 由基座按「客户编码-两位序号」续号;断言草稿里真长出了带号的这一行。
|
||||
*/
|
||||
acceptEdits: {
|
||||
address: 'customer.contact',
|
||||
rows: [
|
||||
{
|
||||
customer_code: 'CU-E2E-ADD',
|
||||
contact_code: '',
|
||||
name: '测试副总李工',
|
||||
title: '副总经理',
|
||||
decision_node: 'bt-03',
|
||||
influence: 'high',
|
||||
relationship: '2',
|
||||
key_person: 'true',
|
||||
},
|
||||
],
|
||||
minted: { column: 'contact_code', prefix: 'CU-E2E-ADD-' },
|
||||
},
|
||||
/*
|
||||
* **复刻 2026-08-24 柴浩的实测**:覆盖范围确认那张卡上人加一家台账里不存在的
|
||||
* 客户 —— 从前它落进了台账,而计划/回执/收口把它从头无视到尾,released 照发。
|
||||
* 现在覆盖闭合(计划客户数 = 候选行数)钉在 S3 收步/S4 哨兵/放行 L2 三处,
|
||||
* 这一家没进计划这一单就走不下去;expectPlanned 再显式对一遍。
|
||||
*/
|
||||
adds: {
|
||||
address: 'crm_coverage.candidate',
|
||||
rows: [
|
||||
{
|
||||
customer_code: 'CU-E2E-ADD',
|
||||
customer_name: '端到端新增客户',
|
||||
cust_type: '组装厂',
|
||||
bt_level: 'bt-03',
|
||||
depth: 'standard',
|
||||
basis: '人工核对时额外纳入(e2e)',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectPlanned: { address: 'crm_coverage.plan', column: 'customer_code', value: 'CU-E2E-ADD' },
|
||||
},
|
||||
{
|
||||
sample: 1,
|
||||
@@ -562,10 +615,14 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
* 序列走完取末项兜底。没有 decideSeq 的老套件仍走单值 `decide`。
|
||||
*/
|
||||
const want = kase.decideSeq === undefined ? kase.decide : kase.decideSeq[Math.min(answered.size, kase.decideSeq.length - 1)];
|
||||
const picked = options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0];
|
||||
// 'custom:…' = 写具体意见驳回(与人在输入框里写一句话同一条链)
|
||||
const custom = typeof want === 'string' && want.startsWith('custom:');
|
||||
const picked = custom
|
||||
? { value: want, label: `意见:${want.slice(7)}` }
|
||||
: (options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]);
|
||||
await api('/api/desk/chat-cards/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: '试跑代答' } }),
|
||||
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
|
||||
});
|
||||
answered.add(card.id);
|
||||
count += 1;
|
||||
@@ -574,6 +631,27 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据录入卡代答(E04 查无联系人的候选:S3 先收第一位关键人再排计划)。
|
||||
* 人确认那一下基座直写草稿 —— 这里只按案例声明的 entryValues 把表单交回去。
|
||||
*/
|
||||
async function answerEntries(taskId, kase, answered) {
|
||||
if (!kase.entryValues) return 0;
|
||||
const { body } = await api(`/api/desk/tasks/${taskId}`);
|
||||
let count = 0;
|
||||
for (const card of body.chat ?? []) {
|
||||
if (card.k !== 'entry' || card.chosen !== undefined || answered.has(card.id)) continue;
|
||||
await api('/api/desk/chat-cards/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply: { k: 'entry', cardId: card.id, values: kase.entryValues } }),
|
||||
});
|
||||
answered.add(card.id);
|
||||
count += 1;
|
||||
console.log(` 录入卡代答:${(card.ask ?? '').slice(0, 40)} → ${JSON.stringify(kase.entryValues)}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把案例声明的格子对齐成保存载荷。行键随套件走(`rowKey`,老套件默认 `pn`):
|
||||
* 键为空串 = 按序发给待填行;`fillMissing` 单列兜底扫空;
|
||||
@@ -699,7 +777,19 @@ async function runCase(suite, kase) {
|
||||
console.log(`\n▶ ${suite.activityId} · ${kase.name}`);
|
||||
const { body: started } = await api('/api/gov/testbed/run-activity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ activityId: suite.activityId, data: sample.input, message: sample.message ?? '', ownerId: '' }),
|
||||
/*
|
||||
* **e2e 显式关掉「落真库」** —— 起单接口默认是开的(人手点那条路要看见数据真变),
|
||||
* 而这道保护正是为 e2e 设的:A1 的「新客户来函」样例一旦把客户真写进主数据,
|
||||
* 第二次跑那条路就成了老客户,用例从此测的不是它要测的东西。
|
||||
* **要保护的那个调用方自己说出来**,别指望一个默认值替所有人做决定。
|
||||
*/
|
||||
body: JSON.stringify({
|
||||
activityId: suite.activityId,
|
||||
data: sample.input,
|
||||
message: sample.message ?? '',
|
||||
ownerId: '',
|
||||
landMaster: false,
|
||||
}),
|
||||
});
|
||||
const taskId = started.taskId;
|
||||
console.log(` 起单 ${taskId}`);
|
||||
@@ -707,12 +797,24 @@ async function runCase(suite, kase) {
|
||||
const replied = new Set();
|
||||
const retries = new Map();
|
||||
const decided = new Set();
|
||||
// 录入卡的已答名单单独记 —— 与 decided 共用会把 decideSeq 按 size 取的序号顶掉(实测:点名驳回被跳过)
|
||||
const entered = new Set();
|
||||
let gateBlocked = false;
|
||||
/** 资料文件那一次检查的结论(在办中验,见下面那段注释) */
|
||||
let fileCheck;
|
||||
let gateLeaked = false;
|
||||
const gateChecked = new Set();
|
||||
let decisions = 0;
|
||||
/** 人加行只加一次;加的那家进没进计划,在办中盯(放行会把草稿消费掉,收工再看就晚了) */
|
||||
let addedRows = false;
|
||||
let planned = false;
|
||||
let revised = false;
|
||||
let acceptEdited = false;
|
||||
let minted = false;
|
||||
let forbidHit = false;
|
||||
let bailed = false;
|
||||
let receiptChecked = false;
|
||||
let receiptMismatch = false;
|
||||
const deadline = Date.now() + 20 * 60 * 1000;
|
||||
let last;
|
||||
|
||||
@@ -736,7 +838,7 @@ async function runCase(suite, kase) {
|
||||
return hit;
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
while (Date.now() < deadline && !bailed) {
|
||||
await sleep(3000);
|
||||
const { body } = await api(`/api/gov/testbed/activity/${taskId}`);
|
||||
last = body;
|
||||
@@ -771,14 +873,52 @@ async function runCase(suite, kase) {
|
||||
console.log(` 资料文件:${fileCheck.note}`);
|
||||
}
|
||||
|
||||
if (kase.expectPlanned && !planned) {
|
||||
const { address, column, value } = kase.expectPlanned;
|
||||
planned = draftRows(body.drafts ?? [], address).some((r) => String(r[column] ?? '') === value);
|
||||
}
|
||||
if (kase.expectRevised && !revised) {
|
||||
const { address, where, equals, column, contains } = kase.expectRevised;
|
||||
revised = draftRows(body.drafts ?? [], address).some(
|
||||
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
|
||||
);
|
||||
}
|
||||
|
||||
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
|
||||
decisions += await answerDecisions(taskId, kase, decided);
|
||||
await answerEntries(taskId, kase, entered);
|
||||
|
||||
// 计划里不许出现的字样(占位人名等)—— 一出现就记下,收工判失败
|
||||
if (kase.forbidInPlan && !forbidHit) {
|
||||
const planRows = draftRows(body.drafts ?? [], 'crm_coverage.plan');
|
||||
forbidHit = planRows.some((r) => Object.values(r).some((v) => kase.forbidInPlan.some((w) => String(v ?? '').includes(w))));
|
||||
}
|
||||
|
||||
for (const card of body.cards ?? []) {
|
||||
if (card.done || replied.has(card.id)) continue;
|
||||
if (card.mode === 'execution') {
|
||||
/*
|
||||
* 「需要你判断下一步」是引擎的兜底卡(模型两次输出不成形 / 决断不了)——
|
||||
* 它不属于任何一段,也不会自己好起来;从前每轮打一行警告空转到 20 分钟才超时。
|
||||
* 认出来就当场收工判失败,把轨迹末尾摆出来尸检。
|
||||
*/
|
||||
if (/需要你判断下一步/.test(card.title ?? '')) {
|
||||
console.log(` ⚠️ 模型交回兜底卡(${card.title})—— 这一单到此判失败`);
|
||||
bailed = true;
|
||||
break;
|
||||
}
|
||||
const stage = stageOf(card);
|
||||
if (stage === undefined) continue;
|
||||
// 回执单派出那一刻,骨架必须与计划一一对应(2026-08-24:计划 7 回执 6 的实测账)
|
||||
if (stage.address === 'crm_coverage.receipt' && !receiptChecked) {
|
||||
receiptChecked = true;
|
||||
const planN = draftRows(body.drafts ?? [], 'crm_coverage.plan').length;
|
||||
const rcptN = draftRows(body.drafts ?? [], 'crm_coverage.receipt').length;
|
||||
if (planN !== rcptN) {
|
||||
console.log(` ⚠️ 回执骨架 ${rcptN} 行 ≠ 计划 ${planN} 行 —— 派单前没对齐,判失败`);
|
||||
receiptMismatch = true;
|
||||
} else console.log(` 回执骨架与计划一一对应(${planN} 行)`);
|
||||
}
|
||||
if (stage.expectGateBlock === true && !gateChecked.has(stage.address)) {
|
||||
gateChecked.add(stage.address);
|
||||
const verdict = await expectGateBlocks(card.id, taskId, stage);
|
||||
@@ -793,6 +933,12 @@ async function runCase(suite, kase) {
|
||||
);
|
||||
}
|
||||
const lines = buildEdits(stage, body.drafts ?? []);
|
||||
// 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增
|
||||
if (kase.adds && kase.adds.address === stage.address && !addedRows) {
|
||||
lines.push(...kase.adds.rows);
|
||||
addedRows = true;
|
||||
console.log(` 件上加 ${kase.adds.rows.length} 行(${kase.adds.rows.map((r) => Object.values(r)[0]).join('、')})`);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
@@ -824,6 +970,30 @@ async function runCase(suite, kase) {
|
||||
console.log(` 交卷执行卡:${card.title}`);
|
||||
replied.add(card.id);
|
||||
} else if (card.mode === 'acceptance') {
|
||||
if (kase.acceptEdits && !acceptEdited) {
|
||||
acceptEdited = true;
|
||||
const { address, rows, minted: mintSpec } = kase.acceptEdits;
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tables: { [address]: rows } }),
|
||||
});
|
||||
const after = await api(`/api/gov/testbed/activity/${taskId}`);
|
||||
/*
|
||||
* 按**我们填的姓名**找行,不能只看编码前缀:错配覆盖的那次(2026-08-24),
|
||||
* 模型自己建的 CU-E2E-ADD-01 也满足前缀 —— 断言过了,而人加的行其实把它顶掉了。
|
||||
* 名对上 + 号续上,才是「真加了一行」。
|
||||
*/
|
||||
const mineRow = rows[0] ?? {};
|
||||
const got = draftRows(after.body.drafts ?? [], address).find(
|
||||
(r) => String(r['name'] ?? '') === String(mineRow['name'] ?? '') && String(r[mintSpec.column] ?? '').startsWith(mintSpec.prefix),
|
||||
);
|
||||
minted = got !== undefined;
|
||||
console.log(
|
||||
minted
|
||||
? ` 确认屏上补一行联系人 → 基座续号 ${got[mintSpec.column]}`
|
||||
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
|
||||
);
|
||||
}
|
||||
await api('/api/desk/work-orders/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
|
||||
@@ -837,7 +1007,7 @@ async function runCase(suite, kase) {
|
||||
const rejects = rejectedCount(last?.trail ?? []);
|
||||
const fired = (last?.trail ?? []).find((t) => t.kind === 'release-verdict' && t.detail?.ports);
|
||||
const ports = fired?.detail?.ports ?? [];
|
||||
let ok = last?.terminal === 'released' && rejects === 0;
|
||||
let ok = !bailed && last?.terminal === 'released' && rejects === 0;
|
||||
console.log(
|
||||
` → ${last?.terminal ?? '超时'}${last?.reason ? `(${last.reason})` : ''} · 被拒 ${rejects} · ` +
|
||||
`${last?.used?.steps ?? '?'} 拍 / ${Math.round((last?.used?.tokens ?? 0) / 1000)}k token · ` +
|
||||
@@ -855,6 +1025,23 @@ async function runCase(suite, kase) {
|
||||
if (kase.expectPort && !ports.includes(kase.expectPort)) {
|
||||
console.log(` ⚠️ 期望口 ${kase.expectPort} 未在发射名单里`);
|
||||
}
|
||||
if (kase.expectPlanned && !planned) {
|
||||
console.log(` ⚠️ 人加的那家(${kase.expectPlanned.value})没进经营计划 —— 新增行又被无视了,判失败`);
|
||||
ok = false;
|
||||
}
|
||||
if (kase.expectRevised && !revised) {
|
||||
console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`);
|
||||
ok = false;
|
||||
}
|
||||
if (kase.acceptEdits && !minted) {
|
||||
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
|
||||
ok = false;
|
||||
}
|
||||
if (receiptMismatch) ok = false;
|
||||
if (forbidHit) {
|
||||
console.log(` ⚠️ 计划里出现了不该有的字样(${(kase.forbidInPlan ?? []).join('/')})—— 占位人名又回来了,判失败`);
|
||||
ok = false;
|
||||
}
|
||||
if (suite.assetFiles) {
|
||||
if (fileCheck === undefined) {
|
||||
console.log(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');
|
||||
|
||||
Reference in New Issue
Block a user