/** * 配方执行器 —— 逐笔执行 `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>): 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; } { return { async run(recipe, ctx) { const writable = await deps.data.writable(ctx.taskId); const results: Record = {}; let scope: Record = { ...(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 = {}; 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(); let localWrote = 0; for (const { row, outer } of expandSource(action.from, base)) { const computed: Record = {}; for (const [name, expr] of Object.entries(action.fields)) { computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed }); } const key: Record = {}; for (const column of decl.keys) key[column] = String(computed[column] ?? ''); /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ if (decl.keys.some((column) => key[column] === '')) continue; const patch: Record = {}; 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 = {}; 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 = {}; 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 }), }; }, }; }