Archived
fix(loop-engine): 配方留痕改 kind: observation —— result 不在骨架里,永远进不了工作日志
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
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { AutoRecipe } from '@contracts';
|
import type { AutoRecipe } from '@contracts';
|
||||||
import { taskIdSchema, staffIdSchema } 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';
|
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||||
|
|
||||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
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<string, unknown> }> };
|
type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record<string, unknown> }> };
|
||||||
|
|
||||||
|
/** 合成的信封字段 —— 与 `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<string, unknown> | 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 框架,是手写的依赖注入替身。** */
|
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
|
||||||
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
|
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
|
||||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
/** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */
|
||||||
|
const appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }> = [];
|
||||||
const cards = { issued: [] as Array<Record<string, unknown>> };
|
const cards = { issued: [] as Array<Record<string, unknown>> };
|
||||||
const view = (): Record<string, unknown> => {
|
const view = (): Record<string, unknown> => {
|
||||||
const mine: Record<string, unknown[]> = {};
|
const mine: Record<string, unknown[]> = {};
|
||||||
@@ -44,7 +78,13 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
|
|||||||
ask: () => Promise.resolve({ id: 'card-4' }),
|
ask: () => Promise.resolve({ id: 'card-4' }),
|
||||||
unanswered: () => Promise.resolve([]),
|
unanswered: () => Promise.resolve([]),
|
||||||
},
|
},
|
||||||
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
|
memory: {
|
||||||
|
append: (m: { kind: string; title: string; detail?: Record<string, unknown> }) => {
|
||||||
|
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')) },
|
analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||||
sfeel: {
|
sfeel: {
|
||||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||||
@@ -59,7 +99,7 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
|
|||||||
};
|
};
|
||||||
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
|
// 假端口只实现用得到的那几个方法,形状对不上 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('配方执行器', () => {
|
describe('配方执行器', () => {
|
||||||
@@ -339,3 +379,68 @@ describe('card 与 submit', () => {
|
|||||||
expect(quiet.submit).toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,9 +86,15 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
context: AutoContext,
|
context: AutoContext,
|
||||||
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
|
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
|
||||||
if (action.kind === 'say') {
|
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({
|
await deps.memory.append({
|
||||||
taskId: context.taskId,
|
taskId: context.taskId,
|
||||||
kind: 'result',
|
kind: 'observation',
|
||||||
title: evalText(action.text, base),
|
title: evalText(action.text, base),
|
||||||
detail: { auto: `${context.stepId}#${index + 1}` },
|
detail: { auto: `${context.stepId}#${index + 1}` },
|
||||||
});
|
});
|
||||||
@@ -130,11 +136,16 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (localWrote > 0 || localDropped > 0) {
|
if (localWrote > 0 || localDropped > 0) {
|
||||||
|
/*
|
||||||
|
* **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'`
|
||||||
|
* 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。
|
||||||
|
* 这一笔是「系统做了什么」,恰是人回看时最想看的东西,不该当排障内部痕藏起来。
|
||||||
|
*/
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId: context.taskId,
|
taskId: context.taskId,
|
||||||
kind: 'result',
|
kind: 'observation',
|
||||||
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
|
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 };
|
return { wrote: localWrote, dropped: localDropped };
|
||||||
@@ -159,11 +170,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
|
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
|
||||||
const rows = object[action.into];
|
const rows = object[action.into];
|
||||||
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
|
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
|
||||||
|
/* kind: 'observation'、不再标 internal —— 理由同上(project 分支那条注释) */
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId: context.taskId,
|
taskId: context.taskId,
|
||||||
kind: 'result',
|
kind: 'observation',
|
||||||
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
|
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 };
|
scope = { ...scope, ...results };
|
||||||
@@ -252,11 +264,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
|||||||
if (done.stop) break;
|
if (done.stop) break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
/* kind: 'observation'、去掉那个恒为 false 的 internal 键 —— 理由同上 */
|
||||||
await deps.memory.append({
|
await deps.memory.append({
|
||||||
taskId: ctx.taskId,
|
taskId: ctx.taskId,
|
||||||
kind: 'result',
|
kind: 'observation',
|
||||||
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
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 } };
|
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user