fix: 配方只认 liveStep;保存不再无端叫醒任务;prune 不在人填的时候撤行

**I6 · 配方只认 `liveStep`**:`current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`),
它不过滤 `settledIds`。当初这只影响提示词内容;配方接进来之后它驱动**副作用** ——
再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。兜底继续只服务提示词。
同处 `settledIds` 改取 sweep 之后的 `stateRuntime`(`liveStepOf` 吃的是 sweep 之后的
`stateEnabled`,两边得同一份局面)。
**注**:这一形状(既已收掉又在场)我没能在当前 lifecycle 里构造出可达来路 ——
`publishEnabled` 把 Completed/Terminated 挡在 entries 与 inProgress 之外,
`reenter` 又是就地改状态、不留旧实例。用例用的是 `live-step.ts` 注释描述的那种局面
(同 id 两份实例),守的是不变式;详见最终报告。

**F1 · `afterSave` 只在有未答的卡时才 `wake`**:`wake` 对 awaiting-event / awaiting-human
一律拨成 running,推进器随即推一拍并**记一步预算**。而现有九个活动包一个 auto 都没有:
人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
(等人裁决)时被叫醒,那一拍结束还会把它写成 awaiting-event —— 从「等人办事」降级成
「等外部回音」,而等的明明还是人。**这是界面撒谎。**「保存即铺格」的场景恰恰都有卡在场。
用既有的 `humanTool.pendingCards`,不新造。`wake` 失败不再 `.catch(() => false)` 静默 ——
它是「保存即铺格」的唯一触发器,吞掉等于人按了保存、格子没出现、系统一个字不说。

**F3 · `prune` 不在人填的时候撤行**:人在件上「加一行」加出来的行,配方的数据源里当然没有 ——
编辑中途把它悄悄撤掉是最不能接受的一种静默数据丢失。有卡在等人(quiet)那几拍只铺不撤;
同一张表既被 prune 又列在该步 `humanWrites` 里,装包报 warn;规范 §4.6 纪律补两条
(prune 的表不该是人能加行的表 · card 之后那几笔要等人交回之后那一拍才跑)。

I6 2 测(做过变异验证)· F1 5 测 · F3 2+1 测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:42:06 +08:00
co-authored by Claude Opus 5
parent 2d20202b1d
commit 588d5c2dd4
9 changed files with 238 additions and 13 deletions
+39 -5
View File
@@ -1,25 +1,27 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { taskIdSchema } from '@contracts';
import { afterSave } from '../save-wake.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
function spy() {
/** 缺省有一张卡在等人 —— 「保存即铺格」的场景恰恰都是这一种 */
function spy(pending: ReadonlyArray<unknown> = [{ id: 'card-1' }]) {
const calls: string[] = [];
return {
calls,
deps: {
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
humanTool: { pendingCards: () => { calls.push('pending'); return Promise.resolve(pending); } },
},
};
}
describe('件上保存之后', () => {
it('真落下了行:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
it('真落下了行、且有卡在等人:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
const { calls, deps } = spy();
await afterSave(deps, TASK, 3);
expect(calls).toEqual(['repush', 'wake']);
expect(calls).toEqual(['repush', 'pending', 'wake']);
});
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
@@ -28,13 +30,45 @@ describe('件上保存之后', () => {
expect(calls).toEqual([]);
});
it('叫醒失败不抬手:保存本身是成功的,别因为叫不醒把 200 变成 500', async () => {
/*
* **没有卡在等人就别叫醒**(2026-08-29 终审 F1):`wake` 对 awaiting-event / awaiting-human
* 一律拨成 running,推进器随即推一拍并记一步预算 —— 而现有九个活动包一个 auto 都没有,
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
*/
it('没有卡在等人(验收态就是这一种):件照旧重推,**不叫醒**', async () => {
const { calls, deps } = spy([]);
await afterSave(deps, TASK, 3);
expect(calls).toEqual(['repush', 'pending']);
});
it('叫醒失败不抬手,但**不再静默** —— 它是「保存即铺格」的唯一触发器', async () => {
const calls: string[] = [];
const said: string[] = [];
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
const deps = {
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
task: { wake: () => Promise.reject(new Error('库连不上')) },
humanTool: { pendingCards: () => Promise.resolve([{ id: 'card-1' }]) },
};
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
write.mockRestore();
expect(calls).toEqual(['repush']);
expect(said.join('')).toContain('库连不上');
});
it('问不出有没有卡在等人:也不叫醒,照样说一声', async () => {
const said: string[] = [];
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
const calls: string[] = [];
const deps = {
views: { repush: () => Promise.resolve(true) },
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
humanTool: { pendingCards: () => Promise.reject(new Error('库连不上')) },
};
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
write.mockRestore();
expect(calls).toEqual([]);
expect(said.join('')).toContain('库连不上');
});
});
+25 -2
View File
@@ -7,20 +7,43 @@
* 人挑了芯片类型、按了保存,参数格就在这一下之后出现。
*
* 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
*
* **只在这一单有未答的卡时才叫醒**(2026-08-29 终审 F1)。`TaskService.wake` 对
* `awaiting-event` / `awaiting-human` 一律拨成 `running`,推进器随即推一拍并**记一步预算**。
* 而现有九个活动包一个 `auto` 都没有:人每按一次保存就烧掉一步预算、界面闪一次
* 「AI 正在推进这单」,那一拍走到 `cards.unanswered` 就原路返回;更糟的是任务本来
* `awaiting-human`(等人裁决)时被叫醒,那一拍结束会把状态写成 `awaiting-event` ——
* 从「等人办事」降级成「等外部回音」,而实际等的仍是人。**这是界面撒谎。**
* 而「保存即铺格」的场景恰恰都有卡在场(人正对着那张卡在件上填),验收态一点都不碰。
*
* **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
* 下一次推进器扫到它照样会跑。
* 下一次推进器扫到它照样会跑。但**不再静默**:它是「保存即铺格」的唯一触发器,
* 吞掉就等于人按了保存、格子没出现、系统一个字不说。
*/
import type { TaskId } from '@contracts';
function grumble(what: string, error: unknown): void {
const why = error instanceof Error ? error.message : String(error);
globalThis.process.stderr.write(`[save-wake] ${what}:${why}\n`);
}
export async function afterSave(
deps: {
readonly views: { repush(taskId: TaskId): Promise<unknown> };
readonly task: { wake(taskId: TaskId): Promise<unknown> };
/** 这一单正等人交回的卡(工单没办结的 + 对话卡没作答的)—— 用既有那条,不新造 */
readonly humanTool: { pendingCards(taskId: TaskId): Promise<ReadonlyArray<unknown>> };
},
taskId: TaskId,
rows: number,
): Promise<void> {
if (rows <= 0) return;
await deps.views.repush(taskId);
await deps.task.wake(taskId).catch(() => false);
/* 问不出来就不叫醒:宁可少推一拍(推进器下一轮照样扫到它),也不平白烧一步预算 */
const pending = await deps.humanTool.pendingCards(taskId).catch((error: unknown) => {
grumble('问不出这一单有没有卡在等人,这次不叫醒', error);
return [];
});
if (pending.length === 0) return;
await deps.task.wake(taskId).catch((error: unknown) => grumble('叫醒失败,件已重推,等推进器下一轮', error));
}
@@ -641,6 +641,31 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没给主键列:case_ref'))).toBe(true);
});
it('prune 的表又列在 humanWrites 里 → warn(人加的行会被下一拍静默撤掉)', () => {
const base = manifestFixture();
const manifest = {
...base,
assets: {
...base.assets,
businessModel: {
...base.assets.businessModel,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
humanWrites: ['demo.record'],
auto: { do: [{ kind: 'project', into: 'demo.record', prune: true, from: { once: true }, fields: { case_ref: 'a' } }] },
},
],
},
},
} as never;
expect(autoRecipeFindings(manifest).some((f) => f.severity === 'warn' && f.message.includes('humanWrites'))).toBe(true);
});
it('card 没有 basis → error(基座拒发 basis 为空的工单,跑到那儿就停了)', () => {
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] }));
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).toBe(true);
@@ -508,6 +508,23 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
}
}
/*
* **prune 的表不该是人在件上能加行的表**(2026-08-29 终审 F3)。
* `prune` 说的是「这一笔投影出来的行就是全部,多出来的撤掉」;而 `humanWrites`
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
*/
if (action.prune === true && (step.humanWrites ?? []).includes(action.into)) {
out.push({
code: 'schema-invalid',
severity: 'warn',
message:
`${action.into} 既被这一笔 prune 投影、又列在这一步的 humanWrites 里 —— ` +
'人在件上加的行配方的数据源里没有,下一拍对齐会把它撤掉(静默);' +
'要么别 prune 这张表,要么它就不该是人能加行的表',
at: where,
});
}
/*
* **主键列一个都不能少**(2026-08-29 终审 I4)。运行时 `auto-runner` 拿
* `decl.keys` 逐列取值,取不到就当空主键 —— 那一行**静默 `continue`**:
@@ -633,3 +633,36 @@ describe('carded:跳这一笔,不收拍(I3)', () => {
expect([...drafts.values()].map((d) => d.object).sort()).toEqual(['parameter', 'part']);
});
});
/**
* **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。
*
* `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」——
* 而人在件上「加一行」加出来的行,配方的数据源里当然没有。编辑中途把它悄悄撤掉,
* 是最不能接受的一种静默数据丢失(2026-08-02 为人工撤行立过「撤要说出来、点名哪几行」的规矩,
* 这里连说都没说)。
*/
describe('prune:人在填的时候不撤行(F3)', () => {
const SEED: AutoRecipe = {
do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }],
};
const PRUNE: AutoRecipe = {
do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }],
};
it('有卡在等人(quiet):只铺不撤 —— 人刚加的那一行还在', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(SEED, ctx);
const out = await runner.run(PRUNE, { ...ctx, quiet: true });
expect(out.dropped).toBe(0);
expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['A', 'B']);
});
it('人交回之后那一拍照旧对齐 —— 这道门只挡编辑中途,不取消 prune', async () => {
const { runner, drafts, ctx } = harness({});
await runner.run(SEED, ctx);
const out = await runner.run(PRUNE, ctx);
expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
});
});
@@ -2613,3 +2613,63 @@ describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
expect(h.issued).toHaveLength(0);
});
});
/**
* **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**(2026-08-29 终审 I6)。
*
* `current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`),它**不过滤 settledIds**。
* 当初这只影响提示词内容(读错一段无非白费点上下文);配方接进来之后它驱动**副作用**:
* 再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。
*
* 两者要分岔,得有一个项**既在 settledIds 里、又在场**。局面形状取自 `live-step.ts`
* 那条注释描述的那一种:同一个 id 留着一份收掉的实例、又有一份重新入场的
* (`publishEnabled` 的 `byId` 取后者 → 在场;`settledIds` 见到前者 → 已收掉)。
* **注:这一形状在当前 lifecycle 里我没能构造出可达的来路**(`reenter` 是就地改状态、
* 不留旧实例),所以这几条守的是**不变式**而不是复现过的事故 —— 见最终报告 I6 那一段。
*/
describe('auto 步 · 只认 liveStep(I6)', () => {
const REPEAT_MODEL: CaseModel = {
name: '收了还在场',
autoComplete: false,
items: [{ id: 'S1', name: '归一', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
/** 收掉的那份实例 + 重新入场的那份 —— 「既已收掉又在场」 */
const SETTLED_YET_ONSTAGE: CaseRuntime = {
items: [
{ id: 'S1', state: 'Completed', instance: 1 },
{ id: 'S1', state: 'Enabled', instance: 2 },
],
} as never;
const STEP = {
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: {
do: [
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
],
},
};
it('既已收掉又在场的步:配方不跑 —— 不重写台账、不再发一张卡', async () => {
const h = harness({ parsed: REPEAT_MODEL, runtime: SETTLED_YET_ONSTAGE, sfeelCondition: () => false, steps: [STEP] });
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(0);
expect(h.issued.some((i) => String(i.card['title']) === '再来一张')).toBe(false);
});
it('同一个局面下,没收掉的那一步照跑 —— 这道门拦的是「已收掉」,不是「所有兜底」', async () => {
const h = harness({
parsed: REPEAT_MODEL,
runtime: { items: [{ id: 'S1', state: 'Enabled', instance: 1 }] } as never,
sfeelCondition: () => false,
steps: [STEP],
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
});
});
@@ -146,7 +146,15 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
kept.add(keyText(key));
}
let localDropped = 0;
if (action.prune === true) {
/*
* **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。
* `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」——
* 而人在件上「加一行」加出来的行,配方的数据源里当然没有:
* 编辑中途把人刚加的那一行悄悄撤掉,是最不能接受的一种静默数据丢失
* (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。
* 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。
*/
if (action.prune === true && !context.quiet) {
for (const stale of before) {
const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
@@ -1967,11 +1967,24 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 都把 S1 当成当前这一步:喂给模型的做法是 S1 的、推给人的画面也是 S1 的。
* 实测:跑到判定去向了,工作区还停在阶段二那份询价受理需求。
*/
/*
* **取 sweep 之后的那份局面**(2026-08-29 终审 I6)。从前取的是 `runtime`(sweep 之前),
* 而 `liveStepOf` 吃的是 sweep 之后的 `stateEnabled` —— 这一拍刚被 `sweepRipe` 收掉的那一步
* 不在 `settledIds` 里,照旧被当成「还没干完的那一步」。从前这只影响提示词内容;
* 配方接进来之后它驱动**副作用**(再写一遍台账、再 prune 一遍、甚至再发一张卡)。
*/
const settledIds = new Set(
runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
stateRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
);
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
/**
* 认不出在办哪一步时退回「在场的第一个有剧本的项」—— **这个兜底只服务提示词**
* (给模型一份做法总比整本说明书强),**不驱动配方**(2026-08-29 终审 I6)。
* 带 `repetitionRule` 的项收掉之后仍是 Enabled,于是活步都收完的那一拍这里会退回一个
* **已经 Completed 的步** —— 让它的配方再跑一遍就是再写一遍台账、再 prune 一遍,
* carded 为假时甚至再发一张卡。提示词读错一段无非白费一点上下文,副作用可不是。
*/
const current =
liveStep ??
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
@@ -1991,8 +2004,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* · 有 `guide` → 照常往下问模型(半 auto)。
* 出错 → 留痕交人,不重试、不退回模型。
*/
if (current?.auto !== undefined) {
const auto = await autoRunner.run(current.auto, {
if (liveStep !== undefined && liveStep.auto !== undefined) {
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
const current = liveStep;
const recipe = liveStep.auto;
const auto = await autoRunner.run(recipe, {
taskId,
stepId: current.item,
ownerId: (await deps.tasks.of(taskId)).ownerId,
@@ -2102,7 +2118,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
*/
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
if (noDoneWhen && carded && recipeIssuesCard(current.auto) && current.guide.trim() === '') {
if (noDoneWhen && carded && recipeIssuesCard(recipe) && current.guide.trim() === '') {
const active = readyToWork(parsed, stateRuntime, current.item);
const settled = transitionItem(active, current.item, 'Completed');
await deps.memory.append({
+10 -1
View File
@@ -348,7 +348,7 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
(读人写的自由文本、落到具体的行上)恰恰是模型该干的活,收在它前面等于把半 auto 的
后半截整个删掉。写半 auto 的步时记住这条:`guide` 不空,这一步答完卡不会被引擎抢先收掉。
**四条纪律**(每一条都是踩出来的):
**六条纪律**(每一条都是踩出来的):
1. **行由确定的那一侧决定,不由 AI 决定。** 明细表要按类型字典 `each` 出来,AI 的出参只是拿主键
`lookup` 的一张查表 —— 它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。
@@ -357,6 +357,15 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
行数对账交给 `prune` 的结构保证,别写进 `doneWhen`。
3. **每一笔之前刷读数。** 后一笔常要数前一笔刚写下的行。
4. **出错交人,不重试、不退回模型。** 配方抛错是包的 bug;退回模型就是让它去凑。
5. **被 `prune` 投影的表,不该是人在件上能加行的表**(2026-08-29 立)。`prune` 说的是
「这一笔投影出来的行就是全部,多出来的撤掉」;而人在件上「加一行」加出来的行,配方的数据源里
当然没有 —— 下一拍对齐就把它**静默撤掉**了(2026-08-02 为人工撤行立过「撤要说出来、点名哪几行」
的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**;
同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 ——
要人加行的表就别 `prune` 它。
6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止;
人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做,
其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。
---