fix(loop-engine): 配方少写的行要出声,撤掉的行要点名

**M1**:主键算不出来那一行从前是静默 `continue` —— 既不计数也不留痕。
表里少了行,人和判据都查不出为什么。现在这一笔结束时点明少了几行、主键列是哪几个。

**M2**:`prune` 撤行从前只说「撤掉 M 行」。2026-08-02 为人工撤行立过
「撤要说出来、要点名哪几行」的规矩(不说清,下一拍的读者会把人刚删的写回去),
配方这一侧跟上:点名撤了哪几行,封顶 8 个,**并去掉单号那一段** ——
人正在这一单里看,单号是脚下的地面,不是区分行的信息(2026-08-25 同一条口径,
基座那一侧是 `rowLabel`)。为此 `TaskDataGateway.writable` 补一个可选的 `caseKey`。

3 测(少写出声 / 撤行点名 / 名单不带单号)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:43:53 +08:00
co-authored by Claude Opus 5
parent 588d5c2dd4
commit 642bdcef3b
4 changed files with 75 additions and 8 deletions
@@ -54,6 +54,23 @@ export type AutoOutcome = {
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
/**
* 摆给人看的行标识 —— **去掉单号那一段**(2026-08-25 柴浩:「只保留斜杠右边」):
* 人正在这一单里看,单号是脚下的地面,不是区分行的信息。与基座的 `rowLabel` 同一条口径。
* 去完一段都不剩(主键只有单号)就退回整段。
*/
function rowName(key: Readonly<Record<string, string>>, caseKey: string | undefined): string {
const rest = Object.entries(key)
.filter(([column]) => column !== caseKey)
.map(([, value]) => value);
return (rest.length > 0 ? rest : Object.values(key)).join('/');
}
/** 点名封顶 8 个 —— 再多就不是「点名」而是刷屏(拒回主键名单封顶 12 是同一条理由) */
function nameList(names: readonly string[]): string {
return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`;
}
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
return recipe.do.some((action) => action.kind === 'card');
@@ -112,6 +129,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
let localWrote = 0;
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */
let localExisting = 0;
/** 主键算不出来、这一笔没写下去的行数 —— 不出声的话表里少了行谁也查不出为什么(M1) */
let localUnkeyed = 0;
/** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */
const dropNames: string[] = [];
for (const { row, outer } of expandSource(action.from, base)) {
const computed: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.fields)) {
@@ -119,8 +140,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
}
const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */
if (decl.keys.some((column) => key[column] === '')) continue;
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾。**但要数一笔**(M1) */
if (decl.keys.some((column) => key[column] === '')) {
localUnkeyed += 1;
continue;
}
const patch: Record<string, unknown> = {};
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
/*
@@ -160,10 +184,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
if (kept.has(keyText(key))) continue;
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
if (gone) localDropped += 1;
if (gone) {
localDropped += 1;
dropNames.push(rowName(key, decl.caseKey));
}
}
}
if (localWrote > 0 || localDropped > 0 || localExisting > 0) {
if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) {
/*
* **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。
* `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。
@@ -178,7 +205,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
title:
`${decl.label}:写下 ${localWrote} 行` +
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`),
/* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true },
});
}