Archived
fix(loop-engine): 配方失败给人一条真的路;幂等在「反复进入」下也成立
**I1 · 失败不再伪装成「等外部回音」**:从前配方办不下去只 `status:'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡 `running` 的单:卡已答完、 没立监听、没有时限,**再没有任何东西会叫醒它**。人在工作台上看到「等外部回音」, 而外面没有任何回音会来。现照熔断兜底卡的成例:发一张卡把办不下去那一笔的原话 摆给负责人 + 立人事件监听。走到那儿时这一单没有未答的卡(runTurn 开头 `cards.unanswered` 把住),所以不会堆卡。不补 narration —— 那句话 auto-runner 已落成系统观察进了对话区,而且停下来的是系统不是 AI(narration 是 AI 蓝)。 **I2 · 重投不许把已落地的行 un-land**:`DraftStore.put` 的 upsert 尾巴上有 `landed_at = null`,而配方每一拍都把同一张表重投一遍 —— 数据工单确认入库之后 那些行的「已落地」记号被悄悄擦掉,整单放行会再写一遍正式表、再抛一遍 data 事件。 新增两个**配方专用**旗(模型那一路一个字不变):`skipUnchanged`(值没变就不落笔)、 `skipExisting`(「只建不改」的表碰到已在库的键当「不用建」跳过并留痕,不抛 —— 那句拒回是写给模型看的,配方抛出去就是永久停单)。已在库的行也进 `kept`, prune 不许撤它。 M4 一并做了:auto-runner 的假 `draftWrite` 从**覆盖**改成生产的**合并**语义 (`patch || excluded.patch`),并补上 landed / createOnly 两件生产真有的事 —— 「重投不会擦掉人填的格子」这条结论在覆盖语义下根本不成立,旧替身证明不了它。 I1 2 测、I2 5 测、M4 使既有 20 测在真语义下重跑。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -1046,7 +1046,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
patch: Record<string, unknown>;
|
||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
/** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */
|
||||
skipUnchanged?: boolean;
|
||||
/** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */
|
||||
skipExisting?: boolean;
|
||||
}): Promise<{ changed: boolean; skipped?: boolean }> {
|
||||
const allowed = await taskData.writable(input.taskId);
|
||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||
if (target === undefined) {
|
||||
@@ -1079,7 +1083,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
||||
.map((d) => d.key);
|
||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
||||
if (why !== undefined) throw new Error(why);
|
||||
/*
|
||||
* **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是
|
||||
* 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」——
|
||||
* 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿,
|
||||
* 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。
|
||||
*/
|
||||
if (why !== undefined) {
|
||||
if (input.skipExisting === true) return { changed: false, skipped: true };
|
||||
throw new Error(why);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1155,6 +1168,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
|
||||
if (input.dryRun === true) return { changed };
|
||||
/*
|
||||
* **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert
|
||||
* 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。
|
||||
* 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。
|
||||
*/
|
||||
if (input.skipUnchanged === true && !changed) return { changed };
|
||||
await deps.ledger.draftWrite({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
|
||||
@@ -42,7 +42,12 @@ function asLogEntry(captured: { kind: string; title: string; detail: Record<stri
|
||||
|
||||
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
|
||||
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>; landed: boolean }
|
||||
>();
|
||||
/** 「只建不改」的表里已经躺在**正式表**的键(`域.对象|键值…`)—— 生产由 `createOnlyViolation` 判 */
|
||||
const formal = new Set<string>();
|
||||
const notes: string[] = [];
|
||||
/** 配方留下的每一笔完整留痕(kind/title/detail)—— 断言「进没进日志、归谁」走这里 */
|
||||
const appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }> = [];
|
||||
@@ -60,11 +65,42 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
|
||||
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
|
||||
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
|
||||
]),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
||||
/**
|
||||
* **合并语义,不是覆盖**(2026-08-29 终审 M4):生产的 upsert 是
|
||||
* `patch = 草稿.patch || excluded.patch`(`ledger-store.ts:369`)——
|
||||
* 从前这个替身写成 `drafts.set(id, { patch })` 是**覆盖**,
|
||||
* 「重投不会擦掉人填的格子」那条结论在覆盖语义下根本不成立,替身证明不了它。
|
||||
*
|
||||
* 同批补上生产真有、而替身从前没有的两件事:
|
||||
* · 任何一次真落笔都把 `landed` 打回 false —— 生产的 upsert 尾巴上写着 `landed_at = null`;
|
||||
* · 「只建不改」的表碰到已在正式表里的键:默认拒(抛),`skipExisting` 时跳过。
|
||||
*/
|
||||
draftWrite: (i: {
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string>;
|
||||
patch: Record<string, unknown>;
|
||||
skipUnchanged?: boolean;
|
||||
skipExisting?: boolean;
|
||||
}) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
|
||||
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
|
||||
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
|
||||
if (formal.has(id)) {
|
||||
if (i.skipExisting === true) return Promise.resolve({ changed: false, skipped: true });
|
||||
return Promise.reject(new Error(`${i.domain}.${i.object} 里已经有这一行 —— 这张表只许新建库里没有的记录`));
|
||||
}
|
||||
const before = drafts.get(id);
|
||||
const changed =
|
||||
before === undefined ||
|
||||
Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
|
||||
drafts.set(id, {
|
||||
domain: i.domain,
|
||||
object: i.object,
|
||||
key: i.key,
|
||||
patch: { ...(before?.patch ?? {}), ...i.patch },
|
||||
landed: false,
|
||||
});
|
||||
return Promise.resolve({ changed });
|
||||
},
|
||||
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
@@ -99,7 +135,7 @@ function harness(seed: Record<string, unknown> = {}, 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, appended, cards, ctx };
|
||||
return { runner: createAutoRunner(deps as never), drafts, formal, notes, appended, cards, ctx };
|
||||
}
|
||||
|
||||
describe('配方执行器', () => {
|
||||
@@ -123,6 +159,7 @@ describe('配方执行器', () => {
|
||||
object: 'part',
|
||||
key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
|
||||
patch: { role: 'original' },
|
||||
landed: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -444,3 +481,103 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', ()
|
||||
expect(lines[0]?.who).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **幂等在「反复进入」下也得成立**(2026-08-29 终审 I2)。
|
||||
*
|
||||
* 配方每一拍都把同一张表重投一遍。而草稿层的 upsert 尾巴上有 `landed_at = null`
|
||||
* (「再写一笔就不再是已落地」)—— 数据工单确认入库之后再重投,那些行的「已落地」记号
|
||||
* 就被悄悄擦掉,整单放行时会被再写一遍正式表、再抛一遍 data 事件,
|
||||
* 而 CLAUDE.md 对 landed 行的承诺是「整单放行不再写它、不抛事件」。
|
||||
*/
|
||||
describe('幂等 · 重投不许把已落地的行 un-land(I2)', () => {
|
||||
const RECIPE: AutoRecipe = {
|
||||
do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'original' } }],
|
||||
};
|
||||
|
||||
it('值没变就压根不落笔 —— 已落地的记号原样还在', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run(RECIPE, ctx);
|
||||
// 数据工单确认入库:这一行打上「已落地」记号(生产是 `__draft.landed_at`)
|
||||
const row = [...drafts.values()][0]!;
|
||||
row.landed = true;
|
||||
|
||||
const again = await runner.run(RECIPE, ctx);
|
||||
expect(again.wrote).toBe(0);
|
||||
expect([...drafts.values()][0]?.landed).toBe(true);
|
||||
});
|
||||
|
||||
it('值真变了才落笔,那时 un-land 是对的 —— 新的一笔要等它自己的门', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run(RECIPE, ctx);
|
||||
[...drafts.values()][0]!.landed = true;
|
||||
|
||||
await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] },
|
||||
ctx,
|
||||
);
|
||||
expect([...drafts.values()][0]?.landed).toBe(false);
|
||||
expect([...drafts.values()][0]?.patch['role']).toBe('candidate');
|
||||
});
|
||||
|
||||
/*
|
||||
* **合并语义下才有意义的一条**(M4):人在件上填的格子不在配方的 `fields` 里,
|
||||
* 配方重投一遍不该把它擦掉。替身从前是覆盖语义,这条断言在那时是假绿。
|
||||
*/
|
||||
it('人在件上填的那一格,配方重投不擦', async () => {
|
||||
const { runner, drafts, ctx } = harness({});
|
||||
await runner.run(RECIPE, ctx);
|
||||
const row = [...drafts.values()][0]!;
|
||||
row.patch['brand'] = '人填的';
|
||||
|
||||
await runner.run(
|
||||
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: 'A', role: 'candidate' } }] },
|
||||
ctx,
|
||||
);
|
||||
expect([...drafts.values()][0]?.patch).toEqual({ brand: '人填的', role: 'candidate' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **「只建不改」的表碰到已在库的键:对模型是拒回,对配方是常态**(2026-08-29 终审 I2)。
|
||||
*
|
||||
* 那句拒回写的是「别再写它,用 drop 把骨架撤掉」—— 说给模型听的。
|
||||
* 配方没有「换个做法」这回事:抛出去就是一单永久停在那儿,而人看见的是一句他办不了的话。
|
||||
*/
|
||||
describe('createOnly + 配方:已在库的当「不用建」跳过,不抛(I2)', () => {
|
||||
const RECIPE: AutoRecipe = {
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'sub_eval.part',
|
||||
from: { rows: [{ pn: 'IN_LIB' }, { pn: 'NEW' }] },
|
||||
fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' }, role: 'candidate' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('已在库的那一颗跳过、别的照写 —— 不交人', async () => {
|
||||
const { runner, drafts, formal, notes, ctx } = harness({});
|
||||
formal.add('sub_eval.part|W05-1|IN_LIB');
|
||||
const out = await runner.run(RECIPE, ctx);
|
||||
expect(out.failedAt).toBeUndefined();
|
||||
expect(out.wrote).toBe(1);
|
||||
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['NEW']);
|
||||
expect(notes.some((n) => n.includes('已在库、这一笔不用建'))).toBe(true);
|
||||
});
|
||||
|
||||
it('已在库的那一颗**不会被 prune 撤掉** —— 它是这一笔要的行,只是不用建', async () => {
|
||||
const { runner, drafts, formal, ctx } = harness({});
|
||||
// 先铺两行草稿(此刻库里还没有 IN_LIB)
|
||||
await runner.run(RECIPE, ctx);
|
||||
expect(drafts.size).toBe(2);
|
||||
// 数据工单确认入库:IN_LIB 进了正式表(草稿层那一行仍在,打了 landed 记号)
|
||||
formal.add('sub_eval.part|W05-1|IN_LIB');
|
||||
for (const d of drafts.values()) if (d.key['internal_pn'] === 'IN_LIB') d.landed = true;
|
||||
|
||||
const out = await runner.run({ do: [{ ...RECIPE.do[0]!, prune: true } as never] }, ctx);
|
||||
expect(out.dropped).toBe(0);
|
||||
expect([...drafts.values()].map((d) => d.key['internal_pn']).sort()).toEqual(['IN_LIB', 'NEW']);
|
||||
expect([...drafts.values()].find((d) => d.key['internal_pn'] === 'IN_LIB')?.landed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2561,3 +2561,55 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
||||
*
|
||||
* 从前失败只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
||||
* `running` 的单:卡已答完、没立监听、没有时限,再没有任何东西会叫醒它。
|
||||
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来。
|
||||
*/
|
||||
describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
|
||||
const AUTO_MODEL: CaseModel = {
|
||||
name: '一步的活动',
|
||||
autoComplete: false,
|
||||
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
|
||||
const BROKEN = {
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
|
||||
};
|
||||
|
||||
it('发一张卡给负责人 + 立人事件监听 —— 原话摆在卡上,不再伪装成「等外部回音」', async () => {
|
||||
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN] });
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(h.issued[0]?.workOrder).toBe('execution');
|
||||
expect(String(h.issued[0]?.card['title'])).toContain('配方办不下去');
|
||||
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
|
||||
expect(basis.find((b) => b.k === '这一步')?.v).toBe('S1');
|
||||
expect(basis.find((b) => b.k === '办不下去的原因')?.v).toContain('nope.nope');
|
||||
// 立了人事件监听,人一答就叫得醒
|
||||
expect(h.listened).toEqual([{ eventType: 'human' }]);
|
||||
});
|
||||
|
||||
it('这一单还有卡没交回时不重复发 —— 叫了人就等人(runTurn 开头那道门先把住)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
unanswered: [{ id: 'card-9', title: '配方办不下去:「S1」第 1 笔' }],
|
||||
steps: [BROKEN],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
const before = rowsAt(base.scope, `mine:${action.into}`);
|
||||
const kept = new Set<string>();
|
||||
let localWrote = 0;
|
||||
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */
|
||||
let localExisting = 0;
|
||||
for (const { row, outer } of expandSource(action.from, base)) {
|
||||
const computed: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(action.fields)) {
|
||||
@@ -121,8 +123,26 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
if (decl.keys.some((column) => key[column] === '')) continue;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
|
||||
const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
|
||||
if (changed) localWrote += 1;
|
||||
/*
|
||||
* 两个旗都是**配方专用**(2026-08-29 终审 I2),见 `TaskDataGateway.draftWrite` 上的注释:
|
||||
* · `skipUnchanged` —— 值没变就别落笔。草稿层的 upsert 尾巴上有 `landed_at = null`,
|
||||
* 而配方每一拍都把同一张表重投一遍:照写会把数据工单已确认入库的那些行**悄悄 un-land**,
|
||||
* 整单放行时再写一遍正式表、再抛一遍 data 事件。
|
||||
* · `skipExisting` —— 「只建不改」的表碰到已在库的键当「不用建」跳过。
|
||||
* 那句拒回是写给模型看的;配方抛出去就是永久停单,而人看见一句他办不了的话。
|
||||
*/
|
||||
const { changed, skipped } = await deps.data.draftWrite({
|
||||
taskId: context.taskId,
|
||||
domain: decl.domain,
|
||||
object: decl.object,
|
||||
key,
|
||||
patch,
|
||||
skipUnchanged: true,
|
||||
skipExisting: true,
|
||||
});
|
||||
if (skipped === true) localExisting += 1;
|
||||
else if (changed) localWrote += 1;
|
||||
/* 已在库的那一颗也算「这一笔要的行」—— prune 不许把它从草稿层撤掉 */
|
||||
kept.add(keyText(key));
|
||||
}
|
||||
let localDropped = 0;
|
||||
@@ -135,7 +155,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
if (gone) localDropped += 1;
|
||||
}
|
||||
}
|
||||
if (localWrote > 0 || localDropped > 0) {
|
||||
if (localWrote > 0 || localDropped > 0 || localExisting > 0) {
|
||||
/*
|
||||
* **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'`
|
||||
* 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。
|
||||
@@ -144,7 +164,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'observation',
|
||||
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
|
||||
title:
|
||||
`${decl.label}:写下 ${localWrote} 行` +
|
||||
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
|
||||
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`),
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +157,8 @@ const REDO_MARK = '验收被打回重做';
|
||||
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
||||
*/
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
||||
const AUTO_FAIL_MARK = '配方办不下去';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
@@ -1999,7 +2001,49 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
|
||||
});
|
||||
if (auto.failedAt !== undefined) {
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
/*
|
||||
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
||||
*
|
||||
* 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
||||
* `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。
|
||||
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。
|
||||
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
||||
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
||||
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
|
||||
*/
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
const failed = auto.failedAt;
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
||||
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '第几笔', v: `${failed.index + 1}` },
|
||||
{ k: '办不下去的原因', v: failed.message },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
/*
|
||||
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
||||
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
||||
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
||||
*/
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
if (auto.submit !== undefined) {
|
||||
/*
|
||||
|
||||
@@ -59,7 +59,21 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<{ changed: boolean }>;
|
||||
/**
|
||||
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||
*/
|
||||
skipUnchanged?: boolean | undefined;
|
||||
/**
|
||||
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||
*/
|
||||
skipExisting?: boolean | undefined;
|
||||
}): Promise<{ changed: boolean; skipped?: boolean }>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user