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));
}