feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数

三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0);
值没变的写静默跳过(配方不是模型,原地打转不必被看见);
出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。
主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。

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 03:41:34 +08:00
co-authored by Claude Opus 5
parent 58ff4f1b90
commit b0a3cc3a55
8 changed files with 375 additions and 1 deletions
@@ -0,0 +1,178 @@
/**
* 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
*
* 三条硬规矩:
* ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
* 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
* ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
* 「写了等于没写」那条判据是拦模型空转的,配方不是模型;
* 发卡先看 `carded`(worldTouched),发过就不再发。
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
*/
import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts';
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
import { expandSource } from '../domain/auto-source.ts';
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
export class AutoRecipeError extends Error {
constructor(
readonly actionIndex: number,
message: string,
) {
super(message);
this.name = 'AutoRecipeError';
}
}
export type AutoRunnerDeps = {
readonly data: TaskDataGateway;
readonly cards: CardGateway;
readonly memory: MemoryRecorder;
readonly analyze: AnalyzeGateway;
readonly sfeel: Sfeel;
};
export type AutoContext = {
readonly taskId: TaskId;
readonly stepId: string;
readonly ownerId: StaffId;
readonly humanCallCap: number;
/** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */
readonly quiet: boolean;
/** 这一步已经发过卡(worldTouched) */
readonly carded: boolean;
};
export type AutoOutcome = {
readonly wrote: number;
readonly dropped: number;
readonly issuedCardId?: string;
readonly submit?: { readonly rationale: string };
readonly failedAt?: { readonly index: number; readonly message: string };
};
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
export function createAutoRunner(deps: AutoRunnerDeps): {
run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
} {
return {
async run(recipe, ctx) {
const writable = await deps.data.writable(ctx.taskId);
const results: Record<string, unknown[]> = {};
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
let dirty = false;
let wrote = 0;
let dropped = 0;
let issuedCardId: string | undefined;
let submit: { rationale: string } | undefined;
const condition = deps.sfeel.condition;
const lets: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
lets[name] = evalExpr(expr, { scope, lets, condition });
}
async function runAction(
action: AutoAction,
index: number,
base: ExprCtx,
context: AutoContext,
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
if (action.kind === 'say') {
await deps.memory.append({
taskId: context.taskId,
kind: 'result',
title: evalText(action.text, base),
detail: { auto: `${context.stepId}#${index + 1}` },
});
return { wrote: 0, dropped: 0 };
}
if (action.kind === 'project') {
const [domain, object] = action.into.split('.');
const decl = writable.find((w) => w.domain === domain && w.object === object);
if (decl === undefined) {
throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`);
}
const before = rowsAt(base.scope, `mine:${action.into}`);
const kept = new Set<string>();
let localWrote = 0;
for (const { row, outer } of expandSource(action.from, base)) {
const computed: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.fields)) {
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
}
const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */
if (decl.keys.some((column) => key[column] === '')) continue;
const patch: Record<string, unknown> = {};
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
if (changed) localWrote += 1;
kept.add(keyText(key));
}
let localDropped = 0;
if (action.prune === true) {
for (const stale of before) {
const key: Record<string, string> = {};
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 (localWrote > 0 || localDropped > 0) {
await deps.memory.append({
taskId: context.taskId,
kind: 'result',
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into },
});
}
return { wrote: localWrote, dropped: localDropped };
}
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
}
for (const [index, action] of recipe.do.entries()) {
try {
if (dirty) {
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
dirty = false;
}
const base: ExprCtx = { scope, lets, condition };
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;
const done = await runAction(action, index, base, ctx);
wrote += done.wrote;
dropped += done.dropped;
if (done.wrote > 0 || done.dropped > 0) dirty = true;
if (done.cardId !== undefined) issuedCardId = done.cardId;
if (done.submit !== undefined) submit = done.submit;
if (done.stop) break;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await deps.memory.append({
taskId: ctx.taskId,
kind: 'result',
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false },
});
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
}
}
return {
wrote,
dropped,
...(issuedCardId === undefined ? {} : { issuedCardId }),
...(submit === undefined ? {} : { submit }),
};
},
};
}