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
+25 -22
View File
@@ -674,30 +674,33 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
},
},
cards: {
issueAcceptance: async (input) => {
/*
* **这条链的最后一关:默认是「传过去」,不是「丢掉」**(2026-09-01 第三次踩同一个坑改的结构)。
*
* 从前这儿是一个**手写字段清单**的显式对象:上游(契约 → submit → release)一路传下来的东西,
* 只要这里没被点到名就在这一行静默落地,编译不报、运行时无声。已经吃过三次:
* ① `fields` 被写死成 `[]` —— 验收报告只能看不能改(第 12 章③);
* ② `blocks` 漏了一行 —— 卡面正文块恒空;
* ③ `redoable` 漏了一行(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
* 装配层算对了、release 转对了、契约也声明了,到这儿被抹平,那颗钮照旧摆着。
* 两条注释就写在那几行旁边警告「少写一行就静默落地」,照样第三次。**措辞治不了的结构治**:
* 把 context 三样拆出去,**其余整包转交** —— 以后端口上新增一个可选字段,这里不改也到得了。
* 剩下的两处 `as never` 是类型收窄(品牌类型 / 结构宽松的块),不是丢值。
*/
issueAcceptance: async ({ taskId, ownerId, humanCallCap, blocks, checklist, ...draft }) => {
const issued = await humanTool.issueAcceptance(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{ taskId, ownerId, humanCallCap },
{
title: input.title,
basis: [...input.basis],
deliverable: input.deliverable as never,
criteria: [...input.criteria],
actions: input.actions.map((a) => ({ ...a })),
// 人工验收清单照传 —— 与 fields / blocks 同一个坑:这里少一行,上游传了一路的东西就静默落地
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
/*
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
* 验收报告于是只能看不能改(第 12 章③)。
*/
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
/*
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
*/
...(input.blocks === undefined || input.blocks.length === 0
? {}
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
/* **整包转交** —— 端口上新增一个可选字段,这里不改也到得了(见上面那段头注) */
...draft,
/* 下面几行不是「挑字段」,是**类型收窄**:只读转可变、品牌类型、结构宽松的块各要一次 */
basis: [...draft.basis],
deliverable: draft.deliverable as never,
criteria: [...draft.criteria],
actions: draft.actions.map((a) => ({ ...a })),
fields: (draft.fields ?? []).map((f) => ({ ...f })) as never,
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
...(blocks === undefined || blocks.length === 0 ? {} : { blocks: blocks.map((b) => ({ ...b })) as never }),
},
);
return { id: issued.order.id };
@@ -713,6 +713,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
id: o.id,
mode: o.mode,
...(o.mode === 'execution' && o.variant !== undefined ? { variant: o.variant } : {}),
...(o.mode === 'acceptance' && o.redoable !== undefined ? { redoable: o.redoable } : {}),
title: o.title,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
@@ -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 —— 这一步可能已经办完了 */