Archived
R10:评审查实 Task 12 只测了 actorOf 这个纯函数,而 auto-runner.ts 四类留痕
(say / project / analyze / 出错兜底)一直用 kind: 'result',它不在 task-log.ts 的
SKELETON 里,structuredLog 在骨架过滤这一关就把它们全部滤掉,actorOf 判得再对也没用。
project 与 analyze 两笔还带 detail.internal: true,即使 kind 改对也会被 machinery() 挡下,
一并去掉。
新增回归测试:直接跑真实的 auto-runner.ts,把捕获到的 {kind, title, detail} 喂给
structuredLog 全程,断言它出现在结果里且 who 是 system —— 不是照 spec 手写理想形状。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
/**
|
|
* 配方执行器 —— 逐笔执行 `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 recipeIssuesCard(recipe: AutoRecipe): boolean {
|
|
return recipe.do.some((action) => action.kind === 'card');
|
|
}
|
|
|
|
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') {
|
|
/*
|
|
* **kind: 'observation'**(2026-08-29,R10)—— 从前是 `kind: 'result'`,
|
|
* 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志
|
|
* (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。
|
|
* `observation` 在骨架里;不带 `detail.say` 时 `actorOf` 本就归系统。
|
|
*/
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'observation',
|
|
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) {
|
|
/*
|
|
* **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'`
|
|
* 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。
|
|
* 这一笔是「系统做了什么」,恰是人回看时最想看的东西,不该当排障内部痕藏起来。
|
|
*/
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'observation',
|
|
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into },
|
|
});
|
|
}
|
|
return { wrote: localWrote, dropped: localDropped };
|
|
}
|
|
|
|
if (action.kind === 'analyze') {
|
|
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
|
|
const item: Record<string, unknown> = {};
|
|
for (const [name, expr] of Object.entries(action.input.fields)) {
|
|
item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
|
|
}
|
|
return item;
|
|
});
|
|
const { object } = await deps.analyze.run({
|
|
taskId: context.taskId,
|
|
model: action.model,
|
|
instruction: action.instruction,
|
|
payload,
|
|
outputSchema: action.output,
|
|
label: `${context.stepId}#${index + 1}`,
|
|
});
|
|
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
|
|
const rows = object[action.into];
|
|
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
|
|
/* kind: 'observation'、不再标 internal —— 理由同上(project 分支那条注释) */
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'observation',
|
|
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, model: action.model },
|
|
});
|
|
/* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
|
|
scope = { ...scope, ...results };
|
|
return { wrote: 0, dropped: 0 };
|
|
}
|
|
|
|
if (action.kind === 'card') {
|
|
/*
|
|
* **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
|
|
* 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
|
|
*/
|
|
if (context.carded) {
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'observation',
|
|
title: `这一步已经发过卡,不再发第二张`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
|
|
});
|
|
return { wrote: 0, dropped: 0, stop: true };
|
|
}
|
|
const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) }));
|
|
if (action.as === 'decide') {
|
|
const options = action.options ?? [];
|
|
if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有');
|
|
const { id } = await deps.cards.decide({
|
|
taskId: context.taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
ask: evalText(action.ask ?? action.title, base),
|
|
options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })),
|
|
basis,
|
|
itemId: context.stepId,
|
|
/* `CardGateway.decide` 的 variant 只认 'review'(审核卡);card.variant 的类型口子更宽(还有 'data'),
|
|
那是给执行工单用的 —— 这里按值窄化,别把 'data' 误传给决策卡 */
|
|
...(action.variant === 'review' ? { variant: action.variant } : {}),
|
|
});
|
|
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
|
}
|
|
const { id } = await deps.cards.issue({
|
|
taskId: context.taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
workOrder: 'execution',
|
|
itemId: context.stepId,
|
|
card: {
|
|
mode: 'execution',
|
|
title: evalText(action.title, base),
|
|
ask: evalText(action.title, base),
|
|
basis,
|
|
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
|
|
fields: [],
|
|
...(action.variant === 'data' ? { variant: 'data' } : {}),
|
|
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
|
|
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
|
|
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
|
|
},
|
|
});
|
|
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
|
}
|
|
|
|
if (action.kind === 'submit') {
|
|
return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true };
|
|
}
|
|
|
|
/* 五个动词(say/project/analyze/card/submit)上面已全部处理,这里在类型上不可达 —— 留作穷尽性兜底 */
|
|
throw new AutoRecipeError(index, '动词尚未实现');
|
|
}
|
|
|
|
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);
|
|
/* kind: 'observation'、去掉那个恒为 false 的 internal 键 —— 理由同上 */
|
|
await deps.memory.append({
|
|
taskId: ctx.taskId,
|
|
kind: 'observation',
|
|
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
|
detail: { auto: `${ctx.stepId}#${index + 1}` },
|
|
});
|
|
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
|
}
|
|
}
|
|
return {
|
|
wrote,
|
|
dropped,
|
|
...(issuedCardId === undefined ? {} : { issuedCardId }),
|
|
...(submit === undefined ? {} : { submit }),
|
|
};
|
|
},
|
|
};
|
|
}
|