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
@@ -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({