修复:收敛 game-chat 阶段归档的 manifest 捕获身份
manifest 是项目级单例、不带 root/run 身份,跨轮复用同一份文件。 终态会话同步里的异步 manifest 捕获是四处快照写入点中唯一没有校验 "被归档 root 是否仍是当前 root"的一处;该读取若在下一轮接管后才 resolve,可能把掺入新轮活动的清单冻结成旧轮归档快照。 补齐与另外三处一致的 runId 校验。跳过不会饿死归档:开下一轮前的 capturePendingGameChatStageManifestBeforeNextRun 是阻塞门,快照 未冻结时直接报错要求重试。 同时把"main 未终态前 manifest 不参与裁定"这条既有但零覆盖的边界 用回归钉住,并显式记录残余风险:main 终态后 manifest 仍被逐字采信, 跨轮残留可被当作本轮结论。该断言即裁决锚点,改动它意味着重新裁决。 决策记录补 manifest 非轮次身份权威源一条;陷阱记录补新鲜度门控的 时间戳单位错配与 set_task_status 旁路两个坑。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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 和并发冲突一律不进入当前轮投影。
|
||||
|
||||
@@ -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 接管后丢失旧后代,或把新轮证据误接到旧阶段记录。
|
||||
|
||||
Reference in New Issue
Block a user