diff --git a/CLAUDE.md b/CLAUDE.md index a2efa7f..216285e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1857,6 +1857,24 @@ 原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask` 本来就是同一件事,一并退役。 +- **「怎么还有重做??你没改上吗?」—— 手写字段清单第三次把值扔了**(2026-09-01): + 上一条算对了、release 转对了、契约也声明了,而**容器那个显式对象**只转它点到名的那几样, + `redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。**同一处已经吃过三次**: + ①`fields` 写死成 `[]`(验收报告只能看不能改)②`blocks` 漏一行(卡面正文块恒空)③这次。 + 那几行旁边就写着两条「少写一行就静默落地」的警告 —— **措辞治不了的结构治**: + 改成把 context 三样拆出去、**其余整包转交**,以后端口上新增可选字段不改这里也到得了; + 剩下两处 `as never` 是类型收窄,不是丢值。断言钉在**最末端**:试跑观测面的卡带上 `redoable`, + e2e 断言「放行验收卡不摆重做(redoable=false 一路到了卡上)」—— 这类丢值只有末端断言拦得住。 +- **人答得比这一拍收尾还快,这一拍就别报「等人」**(2026-09-01,同一夜第二种时序,又停掉一整例): + **决策卡的回传不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍还在 + `running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,再没有第三样东西会来叫 + (工单回传那一路还有人事件兜底 + 昨夜补的 stale 那一下,决策卡这一路**一样都没有**)。 + 实测:卡 02:06:13.250 发出、**219ms 后**被答,这一拍 .636 才收尾,单子停到 20 分钟超时。 + 修:配方发卡收尾前再看一眼**刚发的这张卡还在不在等** —— 已经答了就照实说「还在跑」, + 推进器下一轮接着处理那份答复;**只认这一张卡**,别的「等外部回音」照旧是等,不会空转成热循环。 + 同批把假端口改老实:**发出去的卡默认就在等**(从前发完卡还回「一张待答的都没有」, + 那是生产里不会有的局面 —— 新那道门正是靠这个分辨的)。 + - **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该 有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串, 不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次, diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 57c04ec..6c2a710 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -2839,6 +2839,7 @@ export declare const testActivityStateRoute: { data: "data"; accept: "accept"; }>>; + redoable: z.ZodOptional; title: z.ZodString; done: z.ZodBoolean; result: z.ZodOptional; diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 0c2ea7b..a4673a2 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -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 }; diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 5564dc1..7064860 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -713,6 +713,7 @@ export function registerGovernanceOpsRoutes(app: Hono, 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) }), diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index 73daa9f..b4c5b51 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -101,6 +101,12 @@ function harness( declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record }>; /** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */ 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, diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index acefe60..d6745af 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -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 —— 这一步可能已经办完了 */ diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 0f7f29f..54bc9e8 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -1067,6 +1067,12 @@ export const testActivityStateRoute = { * (配方递 `accept`,装配层写死只认 `data`,人收到的是「执行工单」)。 */ variant: z.enum(['data', 'accept']).optional(), + /** + * 验收卡**打回得了吗**(2026-09-01 补)。这一路值被静默丢过一次:装配层算对了、 + * release 转对了、契约也声明了,到容器那个**手写字段清单**的显式对象里被抹平 + * (`fields` / `blocks` 之后的第三次)—— 断言必须钉在**最末端**,才拦得住这类丢值。 + */ + redoable: z.boolean().optional(), title: z.string(), done: z.boolean(), result: z.string().optional(), diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 56d96a1..da3f4de 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -651,6 +651,12 @@ const SUITES = { * 那句话必须**排在卡之前**才赶得上人看卡 —— 排在卡后面的 say 要等下一拍才跑。 */ expectRedoNarration: '按你的意见复核了', + /* + * **放行那张验收卡不许还摆着重做**(2026-09-01 用户:「怎么还有重做??你没改上吗?」)—— + * 那一步整个由配方跑完,打回之后没有模型会读那句意见。这个值在链上被静默丢过一次 + * (容器那个手写字段清单),所以断言钉在**最末端**:卡真发出去之后它还在不在。 + */ + expectReleaseNoRedo: true, expectDecision: true, expectPort: 'assessment-released', /* @@ -1718,6 +1724,17 @@ async function runCase(suite, kase) { console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败'); ok = false; } + if (kase.expectReleaseNoRedo === true) { + const acc = (last?.cards ?? []).filter((c) => c.mode === 'acceptance'); + const bad = acc.filter((c) => c.redoable !== false); + if (acc.length === 0) { + console.log(' ⚠️ 一张放行验收卡都没见到,「不摆重做」这条没验成 —— 判失败'); + ok = false; + } else if (bad.length > 0) { + console.log(` ⚠️ 放行验收卡上还摆着重做(redoable=${String(bad[0].redoable)})—— 判失败(这个值又在链上被丢了?)`); + ok = false; + } else console.log(' 放行验收卡不摆重做(redoable=false 一路到了卡上)'); + } if (kase.expectRedoNarration !== undefined) { const said = (last?.trail ?? []).some((e) => String(e.title ?? '').includes(kase.expectRedoNarration)); if (said) console.log(` 复核那一轮在对话区说了话(「${kase.expectRedoNarration}…」)`);