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
+139 -3
View File
@@ -236,11 +236,27 @@ export function rowReadings(
* 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里,
* 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。
*/
/**
* **摆给人看的行标识:主键去掉单标识那一段**(2026-08-25 用户:「只保留斜杠右边部分」)。
*
* 单据表的主键是 `单号 + 业务键`(如 round_ref + contact_code)。缺项红带、执行卡提交闸、
* 拒回主键名单从前把两段都摆(`COV-20260825002227/CU-0142-01`)—— 可人正在**这一单里**
* 看这张件,单号是他脚下的地面,不是区分行的信息;真正认得出行的是右边那一段。
* `caseKey` 是声明里的单标识,照它去掉;去完一段都不剩(主键只有单号)就退回整段。
*/
export function rowLabel(id: Readonly<Record<string, unknown>>, caseKey: string | undefined): string {
const parts = Object.entries(id)
.filter(([k, v]) => k !== caseKey && v !== undefined && v !== null && String(v) !== '')
.map(([, v]) => String(v));
return parts.length > 0 ? parts.join('/') : Object.values(id).map(String).join('/');
}
export function missingReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
caseKey?: string | undefined;
fields: ReadonlyArray<{
name: string;
label?: string | undefined;
@@ -252,7 +268,7 @@ export function missingReadings(
}>,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const { domain, object, fields } of declared) {
for (const { domain, object, caseKey, fields } of declared) {
const rows = mine[`mine:${domain}.${object}`] ?? [];
const keys = fields.filter((f) => f.primaryKey).map((f) => f.name);
const blank = (one: Record<string, unknown>, c: string): boolean =>
@@ -282,7 +298,7 @@ export function missingReadings(
if (gaps.length === 0) continue;
const id: Record<string, string> = {};
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
bad.push(`${rowLabel(id, caseKey)} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`);
}
out[`${domain}_${object}_${suffix}`] = bad.length;
if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';');
@@ -331,6 +347,65 @@ export function tallyReadings(
return out;
}
/**
* **这一列有几个不同的值** —— `{ '域_对象_distinct_<列>': N }`,声明列标 `distinct: true` 才数。
*
* 为什么要有这一类(2026-08-24 柴浩实测:「新增了一行要覆盖的客户,后面推进直接无视了」):
* 覆盖范围确认那张卡上人加的行落进了台账、focus 段里模型也看得见,可 S3 的收步判据
* 只数「计划有没有」(`plan_rows > 0`)—— 计划盖住一家还是两家,没有一个基座数说得出来。
* 模型锚在自己轨迹的「识别出 1 家」上把新行漏掉,引擎照样自动收步、放行一路绿灯。
* 「每家候选客户都进了经营计划」要成为机检,就得有「计划里有几家客户」这个数;
* 它必须由基座数:计划行数 ≠ 客户数(一家客户几位关键人就几行),而模型自报的数
* 恰恰就是这次数漏的那一个。
*
* 只数**本单写下的行**(与 `_rows` 同一基准);空值不算一种 —— 「没写」不是一个取值。
* 声明了的列**一律先铺 0**:未绑定的名字参与比较求值为假,零行也得是一个数。
*/
export function distinctReadings(
mine: Readonly<Record<string, readonly unknown[]>>,
declared: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; distinct?: boolean | undefined }>;
}>,
): Record<string, number> {
const out: Record<string, number> = {};
for (const { domain, object, fields } of declared) {
for (const field of fields) {
if (field.distinct !== true) continue;
const rows = mine[`mine:${domain}.${object}`] ?? [];
const seen = new Set<string>();
for (const row of rows) {
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
const value = String(one[field.name] ?? '').trim();
if (value !== '') seen.add(value);
}
out[`${domain}_${object}_distinct_${field.name}`] = seen.size;
}
}
return out;
}
/**
* **本轮碰过的行,以「落库后的样子」摆出来** —— `touched:域.对象`。
*
* 起因(2026-08-24 柴浩实测):台账更新确认屏的落库预览绑的是 `mine:`(本单草稿),
* 而草稿行只有模型这轮写过的那几列 —— Itelma 档案里既有的客户类型/行业/区域、
* Ekaterina 的职务/角色全不在里面:只读面一片空白,编辑框也空(像是数据丢了)。
* 签字的人要看的是**签下去之后这一行长什么样** = 正式现值 + 本单草稿叠加;
* 但整表不行(没碰过的客户不该出现在「将写入」里)—— 所以按草稿键过滤:
* 本轮碰过哪几行,就摆那几行的叠加后全貌。
*/
export function touchedRows(
merged: ReadonlyArray<unknown>,
draftKeys: ReadonlyArray<Readonly<Record<string, unknown>>>,
): unknown[] {
const rows = merged.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null);
return draftKeys
.map((key) => rows.find((row) => Object.entries(key).every(([k, v]) => String(row[k] ?? '') === String(v ?? ''))))
.filter((r) => r !== undefined);
}
/**
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
*
@@ -782,12 +857,25 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。
*/
const mine: Record<string, unknown[]> = {};
const draftKeysOf: Record<string, Array<Record<string, unknown>>> = {};
for (const draft of await deps.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
(draftKeysOf[`${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key });
for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) {
if (value !== null && value !== undefined) scope[field] = value;
}
}
/*
* `touched:域.对象` —— 本轮碰过的行的**叠加后全貌**(正式现值 + 草稿),
* 给落库预览这类「签下去之后长什么样」的画面用;`域.对象` 整表里本就有
* 叠加行,这里只按草稿键挑出本轮的那几行。不进提示词(restPart 滤掉 ——
* 内容与 mine/整表重叠,白占截断窗)。
*/
const touched: Record<string, unknown[]> = {};
for (const [address, draftKeys] of Object.entries(draftKeysOf)) {
const merged = scope[address];
if (Array.isArray(merged)) touched[`touched:${address}`] = touchedRows(merged, draftKeys);
}
/*
* **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。
@@ -826,8 +914,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...rowKeyList(mine, declared),
...missingReadings(mine, declared),
...tallyReadings(mine, declared),
...distinctReadings(mine, declared),
...toolCallReadings(trail, manifest.declarations.tools),
...mine,
...touched,
};
return enriched;
},
@@ -845,7 +935,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: d.object,
label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
}));
},
/**
@@ -866,6 +956,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: string;
key: Record<string, string | number>;
patch: Record<string, unknown>;
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
dryRun?: boolean;
}) {
const allowed = await taskData.writable(input.taskId);
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
@@ -882,6 +974,39 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`);
}
/*
* **引用列查引用**(2026-08-24,措辞治不了的结构治):声明了 `refersTo` 的列,
* 值必须在被引用的表里真实存在(正式 + 本单草稿叠加)。起因:经营计划的
* contact_code 本该指向真实联系人,模型三次给缺口节点**自造顺延引用码 +
* 占位人名** —— 反例描述、说明书纪律、禁令逐层写全都拦不住;
* 现在写入那一刻当场拒,拒回指路(墙要配路)。
*/
for (const field of target.fields) {
if (field.refersTo === undefined) continue;
const raw = input.key[field.name] ?? input.patch[field.name];
const value = raw === undefined || raw === null ? '' : String(raw).trim();
if (value === '') continue;
const [rd, ro, rc] = field.refersTo.split('.');
if (rd === undefined || ro === undefined || rc === undefined) continue;
const referenced = await deps.ledger.taskView(input.taskId, {
address: { domain: rd, object: ro },
where: {},
orderBy: [],
limit: 500,
offset: 0,
});
const exists = referenced.rows.some((r) => String((r as Record<string, unknown>)[rc] ?? '') === value);
if (!exists) {
const refLabel = allowed.find((a) => a.domain === rd && a.object === ro)?.label ?? `${rd}.${ro}`;
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
}
/*
* **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。
*
@@ -921,6 +1046,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
before === undefined ||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
if (input.dryRun === true) return { changed };
await deps.ledger.draftWrite({
taskId: input.taskId,
address: { domain: input.domain, object: input.object },
@@ -1933,6 +2059,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}
const domain = input.address.slice(0, at);
const object = input.address.slice(at + 1);
/*
* **一笔 write 整批先验后写**(2026-08-24 柴浩实测逼出):从前逐行边验边写,
* 第 2 行撞墙(refersTo 查无此码)时第 1 行已经落了草稿,而轨迹只记「行动被拒」——
* 模型与人都以为一行没写,回执骨架从此少一行(计划 7 / 回执 6),
* 到放行门撞成两堵墙(「每条计划都有回执」vs「每家候选都进了计划」)五拍熔断。
* 被拒就得是一行都没落:先把每一行过一遍门,全过了再落。
*/
for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch, dryRun: true });
}
for (const row of input.rows) {
await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch });
}