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:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
+194 -7
View File
@@ -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(' ⚠️ 整单跑完都没等到一行内容资产 —— 这一步没产出资料');