From 154b2228609b7bca5a82ee613a5100931568f80b Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 07:02:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(loop-engine):=20=E9=85=8D=E6=96=B9=E7=95=99?= =?UTF-8?q?=E7=97=95=E6=94=B9=20kind:=20observation=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20result=20=E4=B8=8D=E5=9C=A8=E9=AA=A8=E6=9E=B6=E9=87=8C,?= =?UTF-8?q?=E6=B0=B8=E8=BF=9C=E8=BF=9B=E4=B8=8D=E4=BA=86=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../loop-engine/__tests__/auto-runner.test.ts | 109 +++++++++++++++++- .../internal/usecase/auto-runner.ts | 27 +++-- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index cf28724..927bfea 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { AutoRecipe } from '@contracts'; import { taskIdSchema, staffIdSchema } from '@contracts'; +import { deriveSide, structuredLog } from '@modules/memory'; +import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory'; import { createAutoRunner } from '../internal/usecase/auto-runner.ts'; const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111'); @@ -8,10 +10,42 @@ const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222'); type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record }> }; +/** 合成的信封字段 —— 与 `task-memory.test.ts` 的 `entry()` 工厂同一套写法 */ +const STAMP: VersionStamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }; +const GOVERNANCE: GovernanceMark = { masked: false, learnable: true }; + +/** + * **把配方运行时真实留下的一笔包成完整 `MemoryEntry`,喂给 `structuredLog` 验收** + * (2026-08-29,R10)。 + * + * 上一轮(Task 12)只测过 `actorOf` 这个纯函数;评审指出那只证明了「归属判定本身没错」, + * 没证明「配方这一笔真的能走到工作日志上」—— `kind` 不在 `SKELETON` 里,或 `detail.internal` + * 为真,都会在 `structuredLog` 更早的一关就把它滤掉,`actorOf` 判得再对也没用。 + * 这里直接拿 `harness()` 从**真实生产代码**(`auto-runner.ts`)捕获到的 `{kind, title, detail}` + * 去跑一遍 `structuredLog` 全程 —— 不是照 spec 手写一个理想形状。 + */ +function asLogEntry(captured: { kind: string; title: string; detail: Record | undefined }): MemoryEntry { + const kind = captured.kind as EntryKind; + return { + id: 'e-1', + taskId: TASK, + seq: 1, + at: '2026-08-29T00:00:00.000Z', + kind, + side: deriveSide(kind), + title: captured.title, + stamp: STAMP, + governance: GOVERNANCE, + ...(captured.detail === undefined ? {} : { detail: captured.detail }), + }; +} + /** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */ function harness(seed: Record = {}, override: { analyze?: AnalyzeStub } = {}) { const drafts = new Map; patch: Record }>(); const notes: string[] = []; + /** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */ + const appended: Array<{ kind: string; title: string; detail: Record | undefined }> = []; const cards = { issued: [] as Array> }; const view = (): Record => { const mine: Record = {}; @@ -44,7 +78,13 @@ function harness(seed: Record = {}, override: { analyze?: Analy ask: () => Promise.resolve({ id: 'card-4' }), unanswered: () => Promise.resolve([]), }, - memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } }, + memory: { + append: (m: { kind: string; title: string; detail?: Record }) => { + notes.push(m.title); + appended.push({ kind: m.kind, title: m.title, detail: m.detail }); + return Promise.resolve(); + }, + }, analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) }, sfeel: { condition: (expr: string, scope: Readonly>) => { @@ -59,7 +99,7 @@ function harness(seed: Record = {}, override: { analyze?: Analy }; const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false }; // 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法 - return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx }; + return { runner: createAutoRunner(deps as never), drafts, notes, appended, cards, ctx }; } describe('配方执行器', () => { @@ -339,3 +379,68 @@ describe('card 与 submit', () => { expect(quiet.submit).toBeUndefined(); }); }); + +/** + * **配方留痕真的到得了工作日志**(2026-08-29,R10,spec §5.9 验收)。 + * + * 四类留痕(say / project / analyze / 出错兜底)各跑一遍真实的 `auto-runner.ts`, + * 把捕获到的 `{kind, title, detail}` 喂给 `structuredLog` —— 断言它**出现在结果里** + * (没被 `SKELETON` / `machinery()` 滤掉)且 `who` 是 `system`。 + */ +describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () => { + it('say 那一笔:进日志且归系统', async () => { + const { runner, appended, ctx } = harness({ n: 2 }); + await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); + expect(appended).toHaveLength(1); + const lines = structuredLog([asLogEntry(appended[0]!)]); + expect(lines).toHaveLength(1); + expect(lines[0]?.who).toBe('system'); + }); + + it('project 那一笔:进日志且归系统', async () => { + const { runner, appended, ctx } = harness({}); + await runner.run( + { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }, + ctx, + ); + expect(appended).toHaveLength(1); + const lines = structuredLog([asLogEntry(appended[0]!)]); + expect(lines).toHaveLength(1); + expect(lines[0]?.who).toBe('system'); + }); + + it('analyze 那一笔:进日志且归系统', async () => { + const { runner, appended, ctx } = harness( + { 'parts.type_param': [{ type_id: 1, param_name: 'A' }] }, + { analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } }, + ); + await runner.run( + { + do: [ + { + kind: 'analyze', + model: 'm', + into: 'items', + instruction: 'x', + input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, + output: { type: 'object' }, + }, + ], + }, + ctx, + ); + expect(appended).toHaveLength(1); + const lines = structuredLog([asLogEntry(appended[0]!)]); + expect(lines).toHaveLength(1); + expect(lines[0]?.who).toBe('system'); + }); + + it('出错兜底那一笔:进日志且归系统', async () => { + const { runner, appended, ctx } = harness({}); + await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); + expect(appended).toHaveLength(1); + const lines = structuredLog([asLogEntry(appended[0]!)]); + expect(lines).toHaveLength(1); + expect(lines[0]?.who).toBe('system'); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index ad6c187..e2275eb 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -86,9 +86,15 @@ export function createAutoRunner(deps: AutoRunnerDeps): { 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: 'result', + kind: 'observation', title: evalText(action.text, base), detail: { auto: `${context.stepId}#${index + 1}` }, }); @@ -130,11 +136,16 @@ export function createAutoRunner(deps: AutoRunnerDeps): { } } 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: 'result', + kind: 'observation', title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`, - detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into }, + detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into }, }); } return { wrote: localWrote, dropped: localDropped }; @@ -159,11 +170,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): { /* `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: 'result', + kind: 'observation', title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`, - detail: { auto: `${context.stepId}#${index + 1}`, internal: true, model: action.model }, + detail: { auto: `${context.stepId}#${index + 1}`, model: action.model }, }); /* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */ scope = { ...scope, ...results }; @@ -252,11 +264,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): { 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: 'result', + kind: 'observation', title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`, - detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false }, + detail: { auto: `${ctx.stepId}#${index + 1}` }, }); return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } }; }