From c4669a06a71ba01b1d56f78ddb6b3e72ea82e97c Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 12 Aug 2026 03:24:10 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=94=B6=E6=95=9B?= =?UTF-8?q?=20game-chat=20=E9=98=B6=E6=AE=B5=E5=BD=92=E6=A1=A3=E7=9A=84=20?= =?UTF-8?q?manifest=20=E6=8D=95=E8=8E=B7=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest 是项目级单例、不带 root/run 身份,跨轮复用同一份文件。 终态会话同步里的异步 manifest 捕获是四处快照写入点中唯一没有校验 "被归档 root 是否仍是当前 root"的一处;该读取若在下一轮接管后才 resolve,可能把掺入新轮活动的清单冻结成旧轮归档快照。 补齐与另外三处一致的 runId 校验。跳过不会饿死归档:开下一轮前的 capturePendingGameChatStageManifestBeforeNextRun 是阻塞门,快照 未冻结时直接报错要求重试。 同时把"main 未终态前 manifest 不参与裁定"这条既有但零覆盖的边界 用回归钉住,并显式记录残余风险:main 终态后 manifest 仍被逐字采信, 跨轮残留可被当作本轮结论。该断言即裁决锚点,改动它意味着重新裁决。 决策记录补 manifest 非轮次身份权威源一条;陷阱记录补新鲜度门控的 时间戳单位错配与 set_task_status 旁路两个坑。 Co-Authored-By: Claude Opus 5 --- apps/ai-game-creator-shell/src/App.tsx | 5 ++ .../tests/agentRuntimeModel.test.ts | 68 +++++++++++++++++++ .../shared-memory/decision-log.md | 11 +++ docs/project-memory/shared-memory/pitfalls.md | 8 +++ 4 files changed, 92 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ec6e662c2..8e21b3717 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1012,8 +1012,13 @@ export function App({ .then((capturedManifest) => { const pendingArchive = gameChatPendingStageRuntimesRef.current.get(archiveKey); + // The manifest has no root/run binding, so a read that resolves after + // the next round took over may already describe that newer round. + // Only freeze it while this root is still the current one; otherwise + // the pre-next-run capture gate supplies the snapshot instead. if ( pendingArchive && + projectSupervisorRuntimeRef.current?.runId === runtime.runId && gameChatManifestHasTerminalPrimaryTask(capturedManifest) ) { pendingArchive.manifestSnapshot = capturedManifest; diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 97532d4bc..c2b7a744a 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -963,6 +963,74 @@ describe('Game-chat source-aware runtime projection', () => { }); }); + test('keeps an unbound manifest out of the verdict until the current main is terminal', () => { + const root = gameChatProjectionRuntime(); + const activeMain = gameChatProjectionMain(root); + // The manifest carries no root/run binding, so a `failed` value cannot be + // attributed to any particular round. A leftover from an earlier round must + // not decide the verdict while this round's main is still running. + const staleFailedManifest = createGameCreationAppManifest( + 'project-1', + '跨轮残留清单', + ); + staleFailedManifest.tasks = staleFailedManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'failed' as const } + : task, + ); + + expect( + projectGameChatPrimaryProgress( + staleFailedManifest, + projectCurrentGameChatRuntimeLineage(root, [activeMain]), + ), + ).toEqual({ completed: 0, total: 1, status: 'running' }); + expect( + projectGameChatPrimaryProgress( + staleFailedManifest, + projectCurrentGameChatRuntimeLineage(root, [ + { ...activeMain, phase: 'waiting-for-delegate-receipts' }, + ]), + ), + ).toEqual({ completed: 0, total: 1, status: 'running' }); + + // Mirror gate: a terminal manifest alone must not unlock archiving either. + const terminalRoot = gameChatProjectionRuntime({ + status: 'completed', + phase: 'completed', + }); + const terminalManifest = createGameCreationAppManifest( + 'project-1', + '归档不得只看清单', + ); + terminalManifest.tasks = terminalManifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + expect( + canArchiveGameChatStage( + projectCurrentGameChatRuntimeLineage(terminalRoot, [ + gameChatProjectionMain(terminalRoot), + ]), + terminalManifest, + ), + ).toBe(false); + + // Recorded residual risk (M0B-2 deferred item): once the current main is + // terminal the manifest is trusted verbatim, so a cross-round leftover is + // still reported as this round's failure. Changing this expectation means + // the deferred ruling was revisited — update the decision log with it. + expect( + projectGameChatPrimaryProgress( + staleFailedManifest, + projectCurrentGameChatRuntimeLineage(root, [ + { ...activeMain, status: 'completed', phase: 'completed' }, + ]), + ), + ).toEqual({ completed: 0, total: 1, status: 'failed' }); + }); + test('derives a playable revision only from current-main smoke then structured preview evidence', () => { const root = gameChatProjectionRuntime(); const main = gameChatProjectionMain(root, { recentEvents: [] }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d6d029433..e2670d355 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,16 @@ # 决策记录 +## 2026-08-12 manifest 不是 game-chat 轮次身份的权威源(M0B-2 挂起项收敛) + +- 背景:M0B-2 遗留一条挂起项——manifest 没有 root/run 绑定,不能安全决定 cached failed manifest 是否应覆盖活跃 main 状态。本条对该项作出处置,使 M0-4 可以收口。 +- 事实基线:`GameCreationAppManifest` / `GameCreationAppTaskState`(TS 与 Rust 双侧)不含任何 run/root/agent 身份字段;manifest 是项目级单例文件、跨轮复用;前端唯一读取入口按 `task.id === 'code-prototype'` 做纯字符串过滤。因此"这份 manifest 属于哪一轮"无法从数据本身判定,这是结构性质而非实现疏漏。 +- 裁决:manifest 不是 game-chat 轮次身份的权威源。轮次结论的权威事实只有 Runtime lineage(`agentId + sessionId + runId + source + parentAgentId + parentRunId`);manifest 只能作为 lineage 判定通过后的补充信号,不得单独裁定当前轮结论,也不得单独解锁阶段归档。 +- 本阶段不加绑定:不给 manifest 或其投影新增 `statusRunId` / `statusSource` 等身份字段,维持 M0B-2「不修改后端 DTO/schema/delivery/route」的范围声明。补字段的方案必须同时覆盖 `update_manifest_task_status_at` 与 `set_task_status` 两条写入路径,否则会制造"校验通过"的假象(详见 pitfalls 2026-08-12 条)。 +- 已收敛的部分:相对既有基线的无条件采信(`buildGameChatProgressEvidence` 直接统计七个 fastPath 任务状态,零 lineage、零终态门),当前投影已把 manifest 读取限制在「lineage 无冲突 + root/main/动态美术 child 全终态 + 当前 main 已终态且非 failed/cancelled」之后。阶段归档快照的四处写入点现已统一要求 `projectSupervisorRuntimeRef.current?.runId` 与被归档 root 一致,包括终态会话同步中的异步捕获。 +- 残余风险(明示保留,不视为回归):当前 main 到达 Runtime 终态 `completed`、而全局 manifest state 尚未被本轮 `manifestInvalidated` 事件刷新时,跨轮残留的 `failed` 仍会被当作本轮结论显示。该窗口实际宽度未量化;三条候选机制(新鲜度门控、root-scoped 永久缓存、manifest 补身份字段)经审查均不可安全落地,故本阶段只记录不实现。 +- 重新裁决触发条件:出现"game-chat 实际成功但界面显示失败"的真实复现;或 `manifestInvalidated` 相对 Runtime 终态推送的延迟被测出经常超过一个前端刷新周期;或产品引入连续自动轮次显著缩短轮次间隔。任一条触发即须重新设计并重新裁决,不得继续沿用本条。 +- 关联:pitfalls.md 2026-08-12 条;decision-log 2026-08-11 game-chat 单主前端投影统一使用 source-aware lineage。 + ## 2026-08-11 game-chat 单主前端投影统一使用 source-aware lineage - 投影身份:game-chat 前端只认 `project-supervisor` 且 source=`project-supervisor-game-chat` 的无父根 Run;其唯一 main 必须是 parent 指向该根、source=`agent-ready-task-scheduler` 的 `code-prototype`;动态美术 child 只允许 parent 指向该 main 的 `art-director | art-asset-plan`,source 为 `agent-delegate | agent-delegate-retry`。旧根直属美术、固定 DAG 节点、错误 source/parent/run 和并发冲突一律不进入当前轮投影。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index eeec3acaa..0b47d4308 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 2026-08-12 给 manifest 加"新鲜度门控"或身份字段的两个陷阱 + +- 现象:想给 game-chat 投影里的 manifest 读取加保护时,两条看起来最自然的路都会失效,且失效方式都是静默的——代码跑得通、测试也能编出来,但保护根本没生效。 +- 陷阱一(时间戳单位):`AgentRuntimeState.updatedAt` 来自后端 `unix_timestamp()`,返回的是**秒**(`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:725-730` 的 `as_secs()`),全链路透传到前端不做任何换算。任何 `observedAt >= main.updatedAt` 形式的新鲜度门控,若 observedAt 取 `Date.now()`(毫秒),两侧相差约 1000 倍,判定恒为真、拒绝不掉任何陈旧读数,等于死代码。前端已有 `gameChatMessageTimestampMilliseconds`(`apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx:130-143`,`< 1_000_000_000_000` 则 ×1000)专门处理这个换算,任何新增的跨端时间比较必须复用它,不要重新裸比。 +- 陷阱二(补身份字段只堵一条路):改写 `task.status` 的写入路径有两条互相独立的。除 `update_manifest_task_status_at`(`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:590-608`)外,还有毫无秩序守卫的 `set_task_status`(同文件 `580-588`,直接 `task.status = status`),其调用方 `record_draft_task_progress`(同文件 `387-413`)把 `code-prototype` 列进批量置 `Completed` 的清单,由用户可随时触发的 `game.generate_draft` 命令调用。只给前者补 `statusRunId` / `statusSource`,后者会原样保留上一次写入的旧身份印记,于是污染写入反而通过校验,比不校验更危险。对照组是 `apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs:613-637` 的 `set_task_status_if_current`,它有 `should_replace_task_status` 秩序判定——两者的不对称本身也是一个待处理项。 +- 处理:本阶段不加机制,manifest 明确降级为 lineage 判定通过后的补充信号(见 decision-log 2026-08-12 条)。将来要做,必须同时覆盖两条写入路径,并统一走毫秒换算 helper。 +- 验证:`apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` 的「keeps an unbound manifest out of the verdict until the current main is terminal」钉住了现有边界与残余风险;该用例最后一条断言即为已记录的残余风险,改动它就意味着重新裁决,必须同步更新决策记录。 + ## 2026-08-11 game-chat 阶段归档不能只保存 current-by-agent map - 现象:前端 Runtime map 以 Agent ID 保存当前记录。新一轮仍会复用 `code-prototype`、`art-director`、`art-asset-plan` 这些 Agent ID;若旧 root 已终态但 main/美术 child 或 manifest 仍在收口,直接从 live map 归档会在新 root 接管后丢失旧后代,或把新轮证据误接到旧阶段记录。