fix(engine): 那面「打不回」的旗在容器里被扔了 —— 手写字段清单第三次丢值,改成整包转交

用户:「评审放行与落账确认这一步的验收卡怎么还有重做??你没改上吗?」——
库里的卡确实没有那面旗:装配层算对了、release 转对了、契约也声明了,而**容器那个显式对象**
只转它点到名的那几样,`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。

同一处已经吃过三次:①fields 写死成 [](验收报告只能看不能改)②blocks 漏一行(卡面块恒空)
③这次。那几行旁边就写着两条「少写一行就静默落地」的警告 —— 措辞治不了的结构治:
context 三样拆出去、**其余整包转交**,以后端口新增可选字段不改这里也到得了。
断言钉在最末端:试跑观测面的卡带上 redoable,e2e 断言「一路到了卡上」。

同批修另一种时序的卡死(同一夜第二次,停掉了一整个 e2e 案例):
**决策卡的回传不发事件、只调 wake**,而 wake 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫
(实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,停到 20 分钟超时)。
修:配方发卡收尾前再看一眼刚发的那张卡还在不在等 —— 已经答了就照实说「还在跑」;
只认这一张卡,别的「等外部回音」照旧是等,不会空转成热循环。
假端口同批改老实:发出去的卡默认就在等(从前发完卡还回「一张待答的都没有」)。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿(不摆重做 / 对话区说了话 /
点名要的结论落下去了 / 只动被点名的行);两处变异验证;契约变更 api-report 已刷新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-09-01 02:36:53 +08:00
co-authored by Claude Fable 5
parent e474275b28
commit 3c563525f1
8 changed files with 159 additions and 24 deletions
@@ -101,6 +101,12 @@ function harness(
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>;
/**
* **发卡之后**那一次 `unanswered` 查询的答案(不给就沿用上面那一份)。
* 「人答得比这一拍收尾还快」测的正是这两次查询给出不同答案的那一刻:
* 发卡时没有待答的卡(所以发得出去),发完再查 —— 那张卡在不在名单里,决定这一拍报「等人」还是「还在跑」。
*/
unansweredAfterIssue?: ReadonlyArray<{ id: string; title: string }>;
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
issueRejects?: string;
/**
@@ -207,7 +213,17 @@ function harness(
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
return { id: `card-${issued.length}` };
},
unanswered: async () => over.unanswered ?? [],
/*
* **发出去的卡默认就在等** —— 假端口从前发完卡还回「一张待答的都没有」,
* 那是生产里不会有的局面(2026-09-01「人答得比这一拍收尾还快」那条新门就靠它分辨)。
* 要测「人答得飞快」的,显式给 `unansweredAfterIssue: []`。
*/
unanswered: async () => {
const first = over.unanswered ?? [];
if (issued.length === 0) return first;
if (over.unansweredAfterIssue !== undefined) return over.unansweredAfterIssue;
return [...first, ...issued.map((_, at) => ({ id: `card-${at + 1}`, title: '(刚发出去的卡)' }))];
},
} as never,
listeners: {
register: async (input: { eventType: string }) => {
@@ -2321,6 +2337,57 @@ describe('auto 步:配方在拍首跑', () => {
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
/*
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停):
* 决策卡的回传**不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍
* 还在 running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫。
* 实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,单子停到超时。
*/
it('刚发的卡已经被答了 → 这一拍照实说「还在跑」,推进器下一轮接着处理', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 发卡时没有待答的卡(所以发得出去);发完再查,那张卡已经不在名单里 = 人在这一拍里答了 */
unanswered: [],
unansweredAfterIssue: [],
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(out.status).toBe('running');
});
it('卡还挂在待答名单里 → 照旧「等人」,不因此空转成热循环', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 不给 `unansweredAfterIssue`:假端口默认「发出去的卡就在等」—— 这一拍该报等人 */
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({
parsed: AUTO_MODEL,
@@ -2271,7 +2271,29 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (current.view !== undefined && current.view !== '') {
await showView(taskId, current.view, touched, current.view);
}
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
/*
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停,第二次卡了一整例:
* 卡 02:06:13.250 发出、**219ms 后**就被答了,而这一拍 .636 才收尾)。
*
* 为什么会没人叫醒:**决策卡的回传不发事件,只调 `wake`** —— 而 `wake` 只翻停着的单子,
* 那一刻这一拍还在 `running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,
* 再没有第三样东西会来叫(工单回传那一路还有人事件兜底,决策卡这一路一样都没有)。
*
* 所以收尾前再看一眼**刚发的这张卡还在不在等**:已经答了就照实说「还在跑」,
* 推进器下一轮接着处理那份答复。**只认这一张卡** —— 别的「等外部回音」照旧是等,
* 不会因此空转成热循环。
*/
const stillPending = await deps.cards.unanswered(taskId);
const answeredAlready = !stillPending.some((c) => c.id === auto.issuedCardId);
if (answeredAlready) {
await deps.memory.append({
taskId,
kind: 'observation',
title: '这张卡刚发出去就被答了 —— 这一拍不算等人,接着跑',
detail: { cardId: auto.issuedCardId, item: current.item, internal: true },
});
}
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: answeredAlready ? 'running' : 'awaiting' };
}
if (auto.wrote > 0 || auto.dropped > 0) {
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */