Archived
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:
@@ -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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract
|
||||
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
||||
import type {
|
||||
AgentPort,
|
||||
AnalyzeGateway,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
@@ -53,6 +54,7 @@ export type TurnRunnerDeps = {
|
||||
readonly skills: SkillGateway;
|
||||
readonly tasks: TaskContext;
|
||||
readonly clock: Clock;
|
||||
readonly analyze: AnalyzeGateway;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user