From f2245fb23d8b7a999d127d805b173bb893d63dc6 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 03:36:29 +0000 Subject: [PATCH 01/19] =?UTF-8?q?=E7=AD=96=E5=88=92=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=8C=89=E5=8E=9F=E5=9E=8B=E6=8B=89=E9=BD=90?= =?UTF-8?q?=EF=BC=9Aheader=20=E5=B8=A6=E4=B8=BB=E9=A2=98=E3=80=81=E6=94=BE?= =?UTF-8?q?=E5=BC=80=E6=8F=90=E9=97=AE=E9=9D=A2=E3=80=81=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 local-scripts/deisgn_agent 原型逐条比对后拉回五处分歧。原型 38 条 run 里 22 条走满 3 轮澄清,本仓库 GUI 实测一次都没到过第 3 轮。 1. header 从固定 8 字的轮号计数器「第N轮·关键决定」改回带主题的 「第N轮·当前要决定:<主题>」,决定台账的 topic 改从 header 取。12 字上限是 三条泳道共用的通用 user.input_request 常量,按 header 形状开策划分支——通用 问询今天能过的明天逐字照过,做游戏 / 做素材拿到的仍是 12 字。 2. 提问名额判据恢复原型三分法:「且影响首个可玩闭环」从「空白」的定义挪回提问 优先级,并恢复「空白或存疑」。此前的定义把「无从判断但不影响闭环」的字段整个 排除在空白之外,可问集合被闭合成 pillars + coreLoop 两项。 3. 有默认建议的字段从「一律先用默认建议,不占轮次」改回「优先用默认建议而不是 提问」——有默认不等于不能问。 4. 默认建议清单换回原型那五条(局长偏好、美术、成长、探索、构建)。摘掉 genre.fusion / targetUsers.coreUsers|preferences|referenceGames / outOfScope: 它们进清单等于把第三顺位「制作边界与 MVP」整条轴默认掉,出稿触发器③「剩余 空白都能由默认建议覆盖」随之在第 3 轮恒真。反幻觉那句按原型结构移到「低幻觉 与 GDD 约束」段,outOfScope 的兜底内容与该段已有的 MVP 范围句逐字重复。 5. 选项数 brief 写「2~3 个」(照通用常量生成)、Runtime 硬校验恰好 3、brief 下文 又写「固定三个选项」,三方打架。统一为恰好 3,裸 3 提成 PLAN_CLARIFICATION_OPTION_COUNT 让 brief 与解析器同源。label 分隔符集合按原型 ^A\s*[·•・::..\-] 从 4 个扩到 8 个,两处都只放宽不收紧。 守门两条:策划 header 的放宽只对「第N轮·」形状生效、同长度的通用 header 仍被 12 字 挡下;分隔符集合对着一份逐字来自原型正则的显式清单断言——遍历集合本身是空转的, 删一个就少测一个。 未动分歧 5/6/7(轴级封锁、内容级禁问、oneLiner 禁问)。 Co-Authored-By: Claude Opus 5 --- .../runtime/plan/supervisor-playbook.md | 2 +- .../prompts/runtime/roles/project-planning.md | 9 +- .../src-tauri/src/agent/prompt.rs | 67 ++++++++---- .../src/agent/runtime_driver/main_loop.rs | 6 +- .../runtime_protocol/planning_coordinator.rs | 103 +++++++++++++++--- .../src/agent/runtime_tools/delegation.rs | 6 +- .../src-tauri/src/delegation.rs | 14 +-- .../tests/collaboration/static_deliveries.rs | 4 +- .../src-tauri/src/user_input.rs | 63 ++++++++++- .../tests/agentRuntimeUserInputCard.test.tsx | 8 +- 10 files changed, 219 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index feef2a6a2..f47b0ba52 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -9,7 +9,7 @@ 【转达的规则】 -- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 +- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 - 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 - 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index 70204da00..3a2afcd41 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -4,17 +4,17 @@ - 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。 - 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 -- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 +- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 - 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。 ## 目标与轮次 - 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。 -- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 -- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 +- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 +- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 -- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 @@ -23,6 +23,7 @@ - A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 - 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 - `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 +- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index fbbb72692..b3df2714b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -872,13 +872,17 @@ mod tests { "brief 要点名这个真实踩过的坑" ); for stated in [ - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS} 字符"), + // 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是 + // 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与 + // 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。 + format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"), - format!( - "{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个" - ), + // 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」 + // 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个 + // 选项」以及 `planning_coordinator` 的硬校验三方打架。 + format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"), ] { assert!( planning.contains(&stated), @@ -985,9 +989,17 @@ mod tests { /// 这条会跟着红。 /// /// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身, - /// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的 - /// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄 - /// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。 + /// 给它们配默认值等于把最该花提问预算的那两项默认掉。 + /// + /// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐: + /// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers` + /// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们 + /// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认 + /// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算 + /// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级 + /// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认 + /// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把 + /// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。 /// /// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的 /// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里 @@ -1003,15 +1015,27 @@ mod tests { planning.contains("**默认建议**"), "role brief 三处引用「默认建议」,清单本身必须在场" ); - for field in [ - "`genre.fusion`", - "`artStyle`", - "`targetUsers.sessionLength`", - "`targetUsers.referenceGames`", - "`outOfScope`", - ] { + for field in ["`artStyle`", "`targetUsers.sessionLength`"] { assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); } + for dimension in ["缺成长时", "缺探索时", "缺构建时"] { + assert!( + planning.contains(dimension), + "默认建议清单缺少原型的维度级缺省 {dimension}" + ); + } + // 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里 + // 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。 + for defaulted in [ + "`genre.fusion` 缺 →", + "`targetUsers.referenceGames` 缺 →", + "`outOfScope` 缺 →", + ] { + assert!( + !planning.contains(defaulted), + "{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真" + ); + } assert!( planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"), "pillars / coreLoop 不得进默认建议清单" @@ -1529,12 +1553,15 @@ mod tests { /// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。 /// - /// `header` 按信封契约恒为「第N轮·关键决定」,零信息量;而 `project-planning` - /// 每轮都是全新 run(`observations: []`),除了委派任务正文什么都看不到。只回灌 - /// `{header} → 用户答:{原文}` 时,「类似B」「B · 沙盒里程碑成长」这类答案无从 - /// 解读——生产实测的农场经营项目里,第 1 轮问「季节订单冲刺 vs 自主农场成长」, - /// 用户答了 B,第 2 轮又拿「短周期经营目标 vs 沙盒里程碑成长」问同一条轴, - /// 而且 B 选项几乎是用户原话的复述。 + /// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户 + /// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`), + /// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时, + /// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里, + /// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营 + /// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。 + /// + /// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是 + /// 哪一个」。两端都得钉。 #[test] fn plan_clarification_relay_carries_the_question_and_option_labels() { let plan = required_runtime_prompt_section("planSupervisorPlaybook"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 95520de11..85516fdf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -3932,9 +3932,9 @@ mod plan_envelope_repair_tests { use super::*; const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 @@ -3988,7 +3988,7 @@ mod plan_envelope_repair_tests { fn a_degenerated_tail_no_longer_burns_a_repair_attempt() { let reply = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ); assert!(game_creator_agent_runtime_plan_envelope_parse_error( GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 4e6bf1374..20d56b640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -5,6 +5,13 @@ use uuid::Uuid; const PLAN_OPTION_A_PREFIX: char = 'A'; const PLAN_OPTION_B_PREFIX: char = 'B'; const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; +/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的 +/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己 +/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、 +/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一 +/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住 +/// brief 与这里同源。 +pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3; const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; #[derive(Clone, Debug, Eq, PartialEq)] @@ -110,14 +117,40 @@ fn exact_plan_child_identity_at( Ok(Some((binding, delivery))) } -fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { - let remainder = question - .question - .strip_prefix(PLAN_QUESTION_PREFIX) +/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。 +/// +/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就 +/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。 +fn plan_header_body(header: &str, round: u32) -> Option<&str> { + let rest = header.trim_start().strip_prefix('第')?.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.parse::().ok()? != round { + return None; + } + let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim(); + // 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-` + // 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。 + let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?; + Some(rest.trim_start()) +} + +/// 决定台账的 `topic` 取自 header。 +/// +/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和 +/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。 +fn plan_question_topic( + question: &AgentRuntimeUserInputQuestion, + round: u32, +) -> Result { + let remainder = plan_header_body(&question.header, round) + .and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX)) .ok_or_else(|| { plan_coordinator_error( "PLAN_INVALID_CLARIFICATION", - "plan question 必须以“当前要决定:”开头", + format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"), ) })?; let topic = remainder @@ -129,7 +162,12 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result bool { let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { @@ -154,6 +192,44 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { /// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 /// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` /// 钉的就是这条不变量。 +#[cfg(test)] +mod option_label_delimiter_tests { + use super::*; + + /// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。 + /// + /// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是 + /// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进 + /// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是 + /// 全角冒号那一次。 + /// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历 + /// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个, + /// 断言恒真。 + const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; + + #[test] + fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() { + for delimiter in PROTOTYPE_DELIMITERS { + for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] { + let label = format!("{prefix}{delimiter}方案短语"); + assert!( + plan_option_label_has_prefix(&label, prefix), + "分隔符 {delimiter:?} 被拒:{label}" + ); + let spaced = format!("{prefix} {delimiter} 方案短语"); + assert!( + plan_option_label_has_prefix(&spaced, prefix), + "带空格写法被拒:{spaced}" + ); + } + } + assert!( + !plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX), + "没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混" + ); + } +} + fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { label == normalized_answer } @@ -178,14 +254,7 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", )); } - let expected_header = format!("第{round}轮·关键决定"); - if question.header != expected_header { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - format!("plan question header 必须精确等于 {expected_header}"), - )); - } - let valid_shape = question.options.len() == 3 + let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT && plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX) && plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX) && question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION; @@ -195,7 +264,9 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan question 必须恰好提供 A、B、需要原型验证三个选项", )); } - plan_question_topic(question)?; + // header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如 + // `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。 + plan_question_topic(question, round)?; Ok(()) } @@ -210,7 +281,7 @@ fn build_plan_clarification_decision_projection( let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) .map_err(|error| error.to_string())?; let question = &answer.question; - let topic = plan_question_topic(question)?; + let topic = plan_question_topic(question, round)?; let decision_id = question.id.replace('_', "-"); let (state, answer_source) = if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 05355ba15..1406ab813 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -256,7 +256,7 @@ impl StaticDelegateHopNote<'_> { rounds_used, rounds_limit, } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", next_round = rounds_used.saturating_add(1), ), } @@ -1730,7 +1730,7 @@ mod tests { "澄清续跑必须写明已用轮次与上限:{clarification}" ); assert!( - clarification.contains("第2轮·关键决定"), + clarification.contains("第2轮·当前要决定:"), "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" ); @@ -1753,7 +1753,7 @@ mod tests { "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" ); assert!( - !exhausted.contains("第4轮·关键决定"), + !exhausted.contains("第4轮·当前要决定:"), "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 16e20945b..d335e8a06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -2560,8 +2560,8 @@ mod tests { serde_json::json!({ "questions": [{ "id": "plan_round_1", - "header": "第1轮·关键决定", - "question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", + "header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用", + "question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", "options": [ { "label": "A · 影子化为可独立移动的暗影分身", @@ -2602,12 +2602,12 @@ mod tests { // abtest-tide2A-2:尾巴是「 马会」。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, + r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, ), // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ), ]; for response in cases { @@ -2628,7 +2628,7 @@ mod tests { // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·当前要决定:一局里玩家靠什么目标获得满足","question":"现在先定核心闭环,才能控制 MVP范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, ); let error = parse_static_delegate_user_input_request(Some(response)) .expect_err("an envelope that stops short of closing must not parse"); @@ -2832,8 +2832,8 @@ mod tests { // 实测形态:option 对象里多写了一个 `id` 字段。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", - "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",", + "\"question\":\"它决定首个可玩闭环长什么样。\",\"options\":[", "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 1f3e6abf4..f2aa6f12f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -515,8 +515,8 @@ fn planning_clarification_question_body_with_labels( let body = serde_json::json!({ "questions": [{ "id": question_id, - "header": format!("第{round}轮·关键决定"), - "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), + "header": format!("第{round}轮·当前要决定:第{round}轮核心取舍"), + "question": format!("第{round}轮核心取舍现在确认后才能继续收敛 Fast GDD。"), "options": [ { "label": label_a, diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 017142cfb..496fe0615 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -41,10 +41,42 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; +/// 立项策划澄清卡的 header 是「这一轮要定的是什么」本身,不是一个 12 字的标题格。 +/// +/// 策划子 Agent 每轮都是全新 run,跨轮只能靠委派正文里转述的既往问答认路;header 带 +/// 主题时它一眼能看出哪几条轴已经关掉。原型(`local-scripts/deisgn_agent`)就是这么 +/// 做的:header 上限 60 字、写成 `第 N 轮 · 当前要决定:…`,决定台账的 `topic` 直接取 +/// 它。本仓库把 header 压成固定 8 字的轮号计数器后这条通路就断了。 +/// +/// 放宽只对 `第{N}轮` 这一种形状生效(`plan_clarification_header_limit`)。通用问询今天 +/// 能过的 header 明天逐字照过——做游戏 / 做素材两条泳道拿到的仍是 12 字上限,这里没有 +/// 任何一条既有请求会因此改变结果。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; +/// 该 header 能用到的字符上限。 +/// +/// 判据是形状而不是身份:这个函数在通用 `user.input_request` 解析路径上,六个调用点里 +/// 有两个(工具计划校验、动作摘要)拿不到 root,问不出「这封信是不是策划链路的」。形状 +/// 判据只放宽、从不收紧——非策划 header 一律走 12 字原路,策划 header 的真正定形由 +/// `planning_coordinator::validate_exact_plan_clarification_question` 逐字兜底。 +fn plan_clarification_header_limit(header: &str) -> usize { + let Some(rest) = header.trim_start().strip_prefix('第') else { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + }; + let rest = rest.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.is_empty() || !rest[digits.len()..].trim_start().starts_with('轮') { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + } + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS +} + /// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 /// /// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 @@ -60,8 +92,10 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + OPTION_SYNTAX_CHARS; + // 取两种 header 里宽的那个:通道窄于 schema 的后果是一封完全合法的策划信封在父 run + // 认领回执时被拒、整条委派链阻断,正是这个常量当初要防的那件事。 let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS - + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + QUESTION_SYNTAX_CHARS; @@ -314,7 +348,7 @@ fn normalize_user_input_questions( } let header = normalize_single_line_user_input_text( &question.header, - AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS, + plan_clarification_header_limit(&question.header), &format!("user.input_request question {} header", question_index + 1), )?; let question_text = normalize_single_line_user_input_text( @@ -1347,6 +1381,27 @@ mod tests { }] } + /// 放宽 header 上限只对策划澄清卡那一种形状生效,且只放宽、不收紧。 + /// + /// 两个方向都得钉:同一条 31 字的 header,带 `第N轮·` 前缀要过(策划卡装的是决定 + /// 主题本身),不带就必须照旧被 12 字挡下——否则这次改动就顺手把做游戏 / 做素材 + /// 的通用问询也放宽了,而那两条泳道本轮不该有任何行为变化。 + #[test] + fn only_the_plan_clarification_header_shape_gets_the_wider_limit() { + let long_topic = "当前要决定:一局里玩家靠什么目标获得满足"; + assert!(long_topic.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS); + + let mut plan_header = valid_questions(); + plan_header[0].header = format!("第1轮·{long_topic}"); + assert!(normalize_user_input_questions(plan_header).is_ok()); + + let mut generic_header = valid_questions(); + generic_header[0].header = long_topic.to_string(); + assert!(normalize_user_input_questions(generic_header) + .expect_err("通用 header 不得因为策划分支被放宽") + .contains(&AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS.to_string())); + } + #[test] fn user_input_questions_require_unique_snake_case_ids_and_two_options() { assert!(normalize_user_input_questions(valid_questions()).is_ok()); @@ -1475,8 +1530,8 @@ mod tests { }; let question = AgentRuntimeUserInputQuestion { id: "route_choice".to_string(), - header: "第1轮·关键决定".to_string(), - question: "当前要决定:首版路线。".to_string(), + header: "第1轮·当前要决定:首版路线".to_string(), + question: "它决定第一批关卡按什么规则组合。".to_string(), options: vec![ AgentRuntimeUserInputOption { label: "接受推荐".to_string(), diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx index cc53b7efb..8779ec102 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx +++ b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx @@ -20,7 +20,7 @@ function clarificationRequest(): AgentRuntimeUserInputRequest { questions: [ { id: 'q1', - header: '第1轮·关键决定', + header: '第1轮·当前要决定:首版路线', question: '这局游戏的重玩动力是什么?', options: [ { label: '分数驱动', description: '刷新纪录后重开' }, @@ -46,7 +46,9 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { , ); - const textarea = screen.getByLabelText('第1轮·关键决定 其他回答'); + const textarea = screen.getByLabelText( + '第1轮·当前要决定:首版路线 其他回答', + ); fireEvent.change(textarea, { target: { value: '玩家自己写的答案' } }); expect((textarea as HTMLTextAreaElement).value).toBe('玩家自己写的答案'); @@ -64,7 +66,7 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { fireEvent.click(screen.getByText('分数驱动')); const textarea = screen.getByLabelText( - '第1轮·关键决定 其他回答', + '第1轮·当前要决定:首版路线 其他回答', ) as HTMLTextAreaElement; expect(textarea.value).toBe('分数驱动'); -- 2.52.0 From e43133875ad2ec18412afdd4582f6ff43b4d1fec Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 06:58:27 +0000 Subject: [PATCH 02/19] =?UTF-8?q?=E4=BF=AE=E9=80=9A=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=8D=A1=E3=80=8C=E4=BF=AE=E6=94=B9/=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E3=80=8D=E8=B7=AF=E7=BA=BF=EF=BC=9Aclaim=20=E9=87=8D=E6=94=BE?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E3=80=81park=20=E6=AD=BB=E9=94=81=E3=80=81?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=E9=A2=9D=E5=BA=A6=E4=B8=B2=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测:出 GDD 后点「修改」而不是「批准」,修订委派从头到尾建不出来。盘上现场 (gameagent-c6948da6 / gameagent-347c9786)逐层剥出三个独立缺陷,前两个叠在一起, 修掉上层才露出下层。 1. claim 快照与 delivery 全等比较 claim 的 structuredResult 是「父 Agent 当时观察到了什么」的冻结快照;审批把 delivery 从 EvidenceReady 原地改写成 UserRevisionRequested 后,快照仍停在 EvidenceReady。claim 提交(含幂等重放)按全等判,于是 agent.run_status 每次 重放都 failed,Supervisor 永远拿不到回执。实测空转 43 轮,报「静态委派 claim 与 delivery 身份或结果冲突」。 改为「delivery 是不是 receipt 的合法后继」:除 contractStatus 外每字段逐字 不变、且方向唯一(EvidenceReady → UserRevisionRequested)才放行。原型没有 claim 这层快照,单一真相就地改,结构上不存在这个冲突。 2. park 死锁(被 1 掩盖) 修掉 1 之后 barrier 能正常求值了,userRevisionPending=1 却被 has_waiting() 计成「有在等的委派」,main_loop 于是 park 成「等待专业 Agent 委派回执 / 回执全部 ready 后自动唤醒当前父 run」——那条回执只能来自本 run 自己要创建的修订委派,等的是自己。实测 8 分钟零事件。 has_waiting() 里那个计数是承重的:三处自动恢复路径靠它挡住自动唤醒, runtime_tools/delivery.rs 现场还有 debug_assert 钉着这份依赖。所以不动它, 另加 has_external_wait()(只计 waitingDelegations / unknownContractStatus), 两处 park 决策点改用它。两个谓词问的是相反的问题:自动恢复该不该收手 vs 当前这轮该不该 park。落进 main_loop 本来就写好的 user_revision_pending 分支 (phase=planning、next_step=调用 agent.delegate)即可。 3. 用户修订与质量返工共用「唯一返工轮」文案 用户修订跳带 repairOfDelegationId 但不是澄清续跑,落进 Repair 分支拿到 「这是对已认领委派 X 的唯一返工轮」——用户第一次点修改就被告知只能改这一次。 counter 层早就正确(lineage_counters 对该跳原样继承 depth/round),错的只有 这句话。新增 StaticDelegateHopNote::UserRevision,判据是原 delivery 的 contractStatus,并同澄清分支一样加 target == project-planning 门,做游戏 / 做素材的返工跳逐字保持 Repair——那句话是它们 replaceExisting=true 的唯一授权 信号。playbook 第 6 条同步改成「约束的是单条 delivery 不是整条链」。 原型对应的是 USER_REVISION_SOFT_LIMIT = 16,且超过只提示不拒绝。 守门三条,都验过非空转: - revise 端到端补上此前缺失的那一步——mark 与 dispatch 之间的 claim 重放,正是 生产上唯一会失败的地方;并断言 !is_clear() && !has_external_wait() && has_waiting() 三者同时成立。把比较改回全等即复现生产原文错误。 - 128 种计数组合的 detail↔barrier 等价性测试里写死两个谓词的分叉点,合并回一个 就会炸。 - user_revision hop note 不得含「唯一返工轮」,须含「不消耗返工深度」「不是最后一轮」。 Co-Authored-By: Claude Opus 5 --- .../runtime/plan/supervisor-playbook.md | 2 +- .../agent/runtime_actions/project_gates.rs | 34 ++++++++ .../src/agent/runtime_driver/main_loop.rs | 7 +- .../src/agent/runtime_tools/delegation.rs | 76 ++++++++++++++++++ .../src-tauri/src/delegation.rs | 77 ++++++++++++++++++- .../tests/collaboration/static_deliveries.rs | 36 +++++++++ 6 files changed, 228 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index f47b0ba52..9901e42d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -5,7 +5,7 @@ 3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。 4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。 5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。 -6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 +6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。 【转达的规则】 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 7da5fdb62..cbd3a4113 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1017,6 +1017,25 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: & waiting || user_revision_pending || unknown_contract_status } +/// `StaticDelegateCompletionBarrier::has_external_wait()` 的 detail 侧等价物。 +/// +/// 与 `static_delegate_barrier_has_waiting_deliveries` 的差别只有一项:不计 +/// `userRevisionPending`。park 决策必须用这个——用户修订没有任何外部事件可等, +/// park 住就是等自己派出的委派,必然死锁。自动唤醒侧仍然用前者收手。 +pub(in crate::agent) fn static_delegate_barrier_has_external_wait(detail: &str) -> bool { + let waiting = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("waitingDelegations=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + let unknown_contract_status = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("unknownContractStatus=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + waiting || unknown_contract_status +} + pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool { detail .split_whitespace() @@ -1910,6 +1929,21 @@ mod static_delegate_barrier_detail_gate_tests { barrier.user_revision_pending_count > 0, "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" ); + assert_eq!( + static_delegate_barrier_has_external_wait(&detail), + barrier.has_external_wait(), + "has_external_wait() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + // 两个谓词只能在「仅 userRevisionPending」这一种情形上分叉,别的组合必须一致。 + // 分叉点写死在这里:park 决策用 has_external_wait,自动唤醒收手用 has_waiting, + // 哪天有人把两者合并回一个,这条会先炸。 + assert_eq!( + barrier.has_waiting() && !barrier.has_external_wait(), + barrier.user_revision_pending_count > 0 + && barrier.waiting_count == 0 + && barrier.unknown_contract_status_count == 0, + "两个等待谓词只应在「仅用户修订待办」时分叉:{barrier:?}" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 85516fdf2..d2ec3406f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -681,8 +681,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { + // park 只在真有外部事件可等时才对。用户修订待办不是外部事件——那条回执 + // 只能来自本 run 自己创建的修订委派,park 住就是等自己。下面 1700 行附近 + // 的 `user_revision_pending` 分支才是它该去的地方。 let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { - static_delegate_barrier_has_waiting_deliveries(detail) + static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail) }); if waits_for_delivery { @@ -1792,7 +1795,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, )) } else if observation.tool == "runtime.delegate_receipts" - && (static_delegate_barrier_has_waiting_deliveries(detail) + && (static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail)) { Some(( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 1406ab813..948eda19a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -214,12 +214,21 @@ pub(crate) fn observe_agent_runtime_agent_message( /// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 /// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 /// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带 +/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮 +/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就 +/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就 +/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是 +/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。 /// - `None`:普通委派,不加这一段。 pub(in crate::agent) enum StaticDelegateHopNote<'a> { None, Repair { original_delegation_id: &'a str, }, + UserRevision { + original_delegation_id: &'a str, + }, PlanClarification { original_delegation_id: &'a str, rounds_used: u32, @@ -238,6 +247,11 @@ impl StaticDelegateHopNote<'_> { StaticDelegateHopNote::Repair { original_delegation_id, } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。" + ), // 预算用尽:planning_coordinator 出卡时会用 // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 @@ -651,6 +665,29 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( } else { None }; + // 用户修订跳同样带 repairOfDelegationId,但它是人触发的,不该拿到「唯一返工轮」 + // 那句话。判据用原 delivery 的 contractStatus,并同样只作用于立项策划链路: + // `mark_static_delegate_delivery_user_revision_requested_at` 只由策划审批调用, + // 这里再加一道 target 门,做游戏 / 做素材的返工跳逐字保持 Repair 分支。 + let user_revision_hop = match repair_of_delegation_id.as_deref() { + Some(original) + if plan_clarification_rounds.is_none() + && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => + { + match static_delegate_original_awaits_user_revision_at(root, original) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + }; let hop_note = match ( repair_of_delegation_id.as_deref(), plan_clarification_rounds, @@ -662,6 +699,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( rounds_limit, } } + (Some(original_delegation_id), None) if user_revision_hop => { + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } + } (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { original_delegation_id, }, @@ -1688,6 +1730,40 @@ mod tests { /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 + /// 用户修订轮同样不能套返工文案。 + /// + /// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身 + /// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。 + /// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板 + /// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。 + #[test] + fn user_revision_hop_note_is_not_the_repair_round_note() { + let revision = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::UserRevision { + original_delegation_id: "delegation-old", + }, + ) + .expect("render user revision hop note"); + assert!( + !revision.contains("唯一返工轮"), + "用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}" + ); + assert!( + revision.contains("不消耗返工深度"), + "必须写明它不吃返工额度:{revision}" + ); + assert!( + revision.contains("不是最后一轮"), + "必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}" + ); + } + #[test] fn plan_clarification_hop_note_is_not_the_repair_round_note() { let repair = render_static_delegate_task_contract( diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index d335e8a06..67cba1ea8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -278,12 +278,33 @@ impl StaticDelegateCompletionBarrier { && self.unknown_contract_status_count == 0 } + /// 自动恢复/唤醒路径该不该收手。 + /// + /// `user_revision_pending_count` 计在这里是承重的:用户修订是一条显式的 Supervisor + /// 决策边界,在它派出续作之前父 run 绝不能被自动恢复(`runtime_tools/delivery.rs` + /// 的 `debug_assert` 把这份跨文件依赖钉在使用现场)。 pub(crate) fn has_waiting(self) -> bool { self.waiting_count > 0 || self.user_revision_pending_count > 0 || self.unknown_contract_status_count > 0 } + /// 当前正在跑的这一轮,有没有**外部事件**值得 park 着等。 + /// + /// 和 `has_waiting()` 问的是相反的问题,所以刻意不计 `user_revision_pending_count`: + /// - `waitingDelegations > 0`:子 Agent 正在跑,park 等它 —— 会有回执到来。 + /// - `unknownContractStatus > 0`:fail-closed,宁可停下也不按未知状态行动。 + /// - `userRevisionPending > 0`:**没有任何东西在跑**。那条回执只能来自本 run 自己 + /// 创建的修订委派,park 等它就是等自己,必然死锁。 + /// + /// 生产实测:用户点「修改」后 Supervisor park 在「等待专业 Agent 委派回执 / 回执全部 + /// ready 后自动唤醒当前父 run」,8 分钟零事件——它在等一条只有它自己能造出来的回执。 + /// main_loop 里本来就有一条专为 user_revision 写的分支(`phase=planning`、 + /// `next_step=调用 agent.delegate…`),但被上游这道 park 门截胡了。 + pub(crate) fn has_external_wait(self) -> bool { + self.waiting_count > 0 || self.unknown_contract_status_count > 0 + } + pub(crate) fn detail(self) -> String { format!( "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续", @@ -497,6 +518,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at( Ok(delivery) } +/// claim 里的 `structuredResult` 是「父 Agent 在那个 action 上观察到了什么」的冻结 +/// 快照;delivery 是当前真相。两者绝大多数时候必须逐字相等——不等就是漂移或篡改。 +/// +/// 唯一的例外是审批:用户在审批卡上点「修改 / 退回」后, +/// `mark_static_delegate_delivery_user_revision_requested_at` 会把 delivery 从 +/// `EvidenceReady` 原地改写成 `UserRevisionRequested`,而 claim 快照仍停在 +/// `EvidenceReady`。那不是漂移,是一次只由审批产生、且只能朝这个方向走的合法转移; +/// 快照记的那句「当时观察到 evidence-ready」现在依然为真,不该被改写。 +/// +/// 按全等判会把它当成冲突:`agent.run_status` 每次重放这条 claim 都 failed, +/// Supervisor 永远拿不到回执、也就永远建不出修订委派。生产实测卡死在第 43 轮空转, +/// 报「静态委派 claim 与 delivery 身份或结果冲突」。原型没有 claim 这层快照,单一 +/// 真相就地改,结构上不存在这个冲突——这里翻译的是同一个语义:比较的是「delivery 是 +/// 不是 receipt 的合法后继」,不是「两者永远全等」。 +/// +/// 放行面刻意压到最小:除 `contractStatus` 外每个字段都必须逐字不变,且方向唯一。 +fn static_delegate_structured_result_follows_claim_snapshot( + snapshot: Option<&StaticDelegateStructuredResult>, + current: Option<&StaticDelegateStructuredResult>, +) -> bool { + if snapshot == current { + return true; + } + let (Some(snapshot), Some(current)) = (snapshot, current) else { + return false; + }; + if snapshot.contract_status != StaticDelegateContractStatus::EvidenceReady + || current.contract_status != StaticDelegateContractStatus::UserRevisionRequested + { + return false; + } + let mut rebased = current.clone(); + rebased.contract_status = StaticDelegateContractStatus::EvidenceReady; + rebased == *snapshot +} + /// Mark an already claimed, evidence-ready planning delivery as waiting for a /// user-requested revision. Approval is the only producer of this durable /// status; keeping the transition here makes its evidence precondition and @@ -1097,7 +1154,10 @@ fn commit_static_delegate_claim_with_locks_with_budget_at( || delivery.acceptance_criteria != receipt.acceptance_criteria || delivery.expected_artifacts != receipt.expected_artifacts || delivery.repair_of_delegation_id != receipt.repair_of_delegation_id - || delivery.structured_result != receipt.structured_result + || !static_delegate_structured_result_follows_claim_snapshot( + receipt.structured_result.as_ref(), + delivery.structured_result.as_ref(), + ) { return Err(format!( "静态委派 claim 与 delivery 身份或结果冲突:{}", @@ -1216,6 +1276,21 @@ fn static_delegate_original_is_awaiting_clarification( /// /// 该状态只由后续审批工作包写入;本包只让 lineage 重放认识它,不能自行生成或 /// 把其它状态静默映射成它。 +/// 该原 delivery 是否正等着用户提出的修订(而不是质量返工)。 +/// +/// 用户修订和质量返工都带 `repairOfDelegationId`,但额度完全不同:`repair_depth` +/// 防的是 runaway agent,而用户修订每一轮都由人触发,人本身就是循环边界。委派 task +/// 末尾那句「你在这条链路上的位置」必须按这个判据分开渲染,否则用户第一次点修改就会 +/// 被告知「这是唯一返工轮」。 +pub(crate) fn static_delegate_original_awaits_user_revision_at( + root: &Path, + delegation_id: &str, +) -> Result { + Ok(read_static_delegate_delivery_at(root, delegation_id)? + .as_ref() + .is_some_and(static_delegate_original_is_user_revision_requested)) +} + fn static_delegate_original_is_user_revision_requested( delivery: &StaticDelegateDeliveryRecord, ) -> bool { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index f2aa6f12f..f0d4f2548 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -4426,6 +4426,42 @@ fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_ &submitted_delivery.delegation_id, ) .expect("mark submitted delivery user-revision-requested"); + + // 审批改写 delivery 之后、派发修订委派之前,Supervisor 必然先调一次 + // `agent.run_status`,它会把这条已 observed 的 claim 整个重放一遍。claim 里的 + // structuredResult 仍是审批前那份 EvidenceReady 快照,而 delivery 已经是 + // UserRevisionRequested——这一步按全等判就会报「claim 与 delivery 身份或结果 + // 冲突」,Supervisor 从此拿不到回执,也就永远建不出下面那条修订委派。 + // + // 生产实测正是卡在这里:run_status 连续 failed、空转到第 43 轮。此前这个用例 + // 从 mark 直接跳到 dispatch,跳过的恰好是唯一会失败的那一步。 + let barrier = static_delegate_completion_barrier_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("审批改写 delivery 后,claim 重放必须仍然成立"); + + // 屏障必须同时说出两件事:任务还不能收束,但**没有外部事件可等**。 + // + // 只判「不能收束」不够——修复前它正是这样:main_loop 把用户修订待办当成 + // 「有在等的委派」,park 成「等待专业 Agent 委派回执 / 回执全部 ready 后自动 + // 唤醒当前父 run」,而那条回执只能来自下面这条还没派出去的修订委派。生产实测 + // 8 分钟零事件。has_external_wait() 为假才能让本轮落进 user_revision 分支去 + // 调 agent.delegate。 + assert!( + !barrier.is_clear(), + "用户修订待办没派出续作前,父 run 不能被判为可收束" + ); + assert!( + !barrier.has_external_wait(), + "用户修订待办没有任何外部事件可等,park 住就是等自己派出的委派" + ); + assert!( + barrier.has_waiting(), + "自动恢复路径仍须收手:续作派出前不得跨过这条 Supervisor 决策边界" + ); + let revision_action_id = format!("planning-user-{action}-continuation"); let revision = dispatch_static_delegate_plain_repair( &fixture.root, -- 2.52.0 From 9e2648893bf48b19658b2e49224d2c923dab43d2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 08:13:40 +0000 Subject: [PATCH 03/19] =?UTF-8?q?=E4=BF=AE=E9=80=9A=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=E8=BD=AE=E5=87=BA=E7=A8=BF=EF=BC=9A=E6=8A=95=E5=BD=B1=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E4=B8=8D=E5=86=8D=E5=81=87=E8=AE=BE=E3=80=8C=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=20lineage=20=E5=8F=AA=E6=8F=90=E4=BA=A4=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批卡点「修改/退回」后,策划子 Agent 能提交 v2、GDD 也落盘了,但 `project_submit_successors_locked` 拒绝写 session successor,返回 recoveryPending=true。Runtime 把这次工具动作标成 needs-reconciliation, hydrate 再看到「未审批的 v2 vs session 还指着 v1」,抛 PLAN_SESSION_RECOVERY_REQUIRED。UI 上「重试恢复」走的是同一个投影守卫, 永远清不掉;「结束旧任务」不重写 session,同样清不掉——项目就此砖掉。 根因是 source_session_matches_gdd 里的 latest_submitted_ref.is_none()。 它不是安全判据:相邻的 sessionRevision + sessionFingerprint 已经是全内容 CAS——读取路径 parse_plan_session_bytes → validate_plan_session 会重算指纹 并拒绝不自洽的 session,所以指纹相等即意味着 session 与出稿时的快照逐字段 相等。那一条只是「一条 lineage 只提交一次」的残留假设,而同文件的提交闸 validate_current_session_cas 早在 §M1C-2b 那段注释里宣布该假设作废。删掉 之后,投影守卫的判据集与提交闸逐条相同——本来就该是这个关系。 顺带:已经砖掉的项目重放时命中同一条路径补写 successor,点一次「重试恢复」 即可自愈,不需要迁移。 守门扩在既有的 rejected_session_needs_a_collecting_continuation_not_a_wider_submit_gate 上。那条测试本来就构造出了这个 session 形状(带 v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef),却只断言提交闸放行就收尾,差一步没走到。补上真正 提交 v2 并断言 !recovery_pending;把 is_none() 加回去这条会红,已验证非空转。 Co-Authored-By: Claude Opus 5 --- .../agent/runtime_protocol/planning_submit.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index e8dc24c0d..b63d5953f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1717,11 +1717,20 @@ fn project_submit_successors_locked( // successor. The only safe forward path is an exact source-session // snapshot (including the still-active child run) or an already // projected session pointing at this immutable ref. + // These judgements are deliberately the same set `validate_current_session_cas` + // already enforced at the submit gate. Do not narrow them with a + // `latest_submitted_ref.is_none()` style assertion: revision plus the + // recomputed `sessionFingerprint` (`validate_plan_session` rejects a session + // whose fingerprint does not hash its own content) already pin the session to + // the exact snapshot the submitter observed, so any extra field-shape check + // only re-encodes the obsolete "one submission per lineage" rule. A user + // revision round legitimately arrives carrying the previous version's + // `latestSubmittedRef`; refusing it strands a committed GDD behind a + // `recoveryPending` that no replay can clear. let source_session_matches_gdd = session_identity_matches_gdd && previous_session.session_revision == gdd.source_session_revision && previous_session.session_fingerprint == gdd.source_session_fingerprint && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) - && previous_session.latest_submitted_ref.is_none() && matches!( previous_session.phase.as_str(), "collecting" | "revision_requested" @@ -4240,6 +4249,23 @@ mod tests { next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); validate_current_session_cas(&continuation, &next_context, &input) .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + + // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 + // v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef,投影守卫若据此判它不是 + // 合法起点,v2 就会越过提交点却收不了口,留下一个任何重放都清不掉的 + // recoveryPending。 + write_plan_session_atomic_locked(&root, &continuation).expect("write continuation session"); + next_context.action_id = "action-89abcdef0123456789abcdef".to_string(); + next_context.action_fingerprint = "4".repeat(64); + next_context.approval_request_id = + Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); + let resubmit = + execute_plan_submit_gdd(&root, &next_context, &input).expect("continuation 提交 v2"); + assert_eq!(resubmit.gdd_ref.version, 2); + assert!( + !resubmit.recovery_pending, + "提交闸放行的 continuation,投影守卫也必须放行" + ); cleanup_fixture(root); } -- 2.52.0 From b27ffce9b4e585dad03f29e21f250674adb0c7e2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 08:42:42 +0000 Subject: [PATCH 04/19] =?UTF-8?q?=E6=8A=8A=E5=AE=A1=E6=89=B9=E5=8D=A1?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E6=94=B9/=E9=80=80=E5=9B=9E=E6=84=8F?= =?UTF-8?q?=E8=A7=81=E5=8E=9F=E6=96=87=E9=80=81=E8=BF=9B=20Supervisor=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在审批卡上写的意见此前只落到 `.agent/planning/approvals/v{n}.json` 就断了。 Supervisor 唯一的信息源是 `agent.run_status` 返回的 claimedDelegateContract, 那里面只有 contractStatus=user-revision-requested,没有承载原文的字段。于是它 按 playbook 第 6 条「把用户原话完整附在 task 里」写了句占位「请按审批卡上用户 提交的修改意见…」,子 Agent 收到的是空指令,只能自由发挥——实测用户写「把游戏 名称改成日本语」,产出的 v2 把标题从《裂隙脉冲》改成《裂潮航印》,仍是中文。 通道本来就有:决策卡的答案早就是这么送的——`append_user_input_answer_message` 把用户选择渲成一句 role=user 消息追加到 Supervisor 会话,下一轮 prompt 由 `prompt_history_sources` 现读现取。审批决定接上同一条通道即可。 因此不动 delivery schema,也不动 playbook: - schema 加字段要连带改 validator、run_status 投影,还得让 e43133875 的 `static_delegate_structured_result_follows_claim_snapshot` 跟着 rebase 新字段, 否则 claim 重放冲突原样复发——多一处必须手工同步的地方。 - playbook 第 6 条的规则本来就在,缺的是原文本身,不是规则。 落点用 Supervisor 的**当前活动会话**(sessionId 传 None),不用 delivery 上的 parentSessionId——那是委派发出时的快照,不保证仍是可写的活动会话。 messageId 用 responseId 派生:`project_receipt_locked` 会被 `reconcile_plan_gdd_approval_projections_locked` 在每次 hydrate 重跑,不幂等就 每刷新一次多一条。 守门一条,两个断言都验过非空转:去掉追加,第一条断言红;换成非幂等 append, 重放后消息数 2 vs 1,第二条断言红。 Co-Authored-By: Claude Opus 5 --- .../runtime_protocol/planning_approval.rs | 40 +++++++++++++++++ .../agent/runtime_protocol/planning_submit.rs | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 27c300f3d..564f7a6c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -74,6 +74,42 @@ fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservat } } +/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己 +/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道, +/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的 +/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么—— +/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能 +/// 写一句占位,子 Agent 于是自由发挥。 +fn append_plan_gdd_revision_message( + root: &Path, + receipt: &PlanGddApprovalV1, +) -> Result<(), String> { + let label = if receipt.action == "reject" { + "退回" + } else { + "修改" + }; + append_local_conversation_message_for_session_idempotent_at( + root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + // 落在 Supervisor 当前活动会话。delivery 上的 parentSessionId 是委派发出时 + // 的快照,不保证仍是可写的活动会话。 + None, + LocalConversationMessage { + role: "user".to_string(), + content: format!( + "我对 Fast GDD v{} 的审批:{}。意见原文:\n{}", + receipt.version, + label, + receipt.comment.as_deref().unwrap_or_default() + ), + agent_id: None, + }, + &format!("plan-gdd-decision-{}", receipt.response_id), + ) + .map(|_| ()) +} + fn receipt_decision_input( gdd: &PlanGddV1, input: &DecidePlanGddInputV1, @@ -1149,6 +1185,10 @@ fn project_receipt_locked( note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error); recovery_pending = true; } + if let Err(error) = append_plan_gdd_revision_message(root, receipt) { + note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error); + recovery_pending = true; + } } // A replay may target an older receipt after a newer GDD has already been // submitted. The receipt still repairs its own audit/observation, but it diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index b63d5953f..f5dee5b6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -4192,6 +4192,51 @@ mod tests { cleanup_fixture(root); } + /// 审批卡上的「修改/退回」意见必须原文落进 Supervisor 会话——那是 playbook + /// 第 6 条「把用户原话完整附在 task 里」唯一的原话来源。delivery 的 + /// `contractStatus=user-revision-requested` 只说明「用户要改」,不带内容, + /// Supervisor 拿不到原文就只能在返工委派里写一句占位。 + /// + /// 追加必须幂等:`reconcile_plan_gdd_approval_projections_locked` 每次 hydrate + /// 都会为全部回执重跑 `project_receipt_locked`。 + #[test] + fn a_revision_comment_reaches_the_supervisor_conversation_once() { + let (root, gdd, _root_runtime) = acceptance_gate_fixture(true); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "revise", + "gdd-response-00000000-0000-4000-8000-000000000050", + Some("把游戏名称改成日本语".to_string()), + ), + ) + .expect("commit revise receipt"); + let supervisor_messages = || { + read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + None, + ) + .expect("read supervisor conversation") + .messages + }; + let after_decision = supervisor_messages(); + assert!( + after_decision.iter().any(|message| message.role == "user" + && message.content.contains("把游戏名称改成日本语")), + "用户修改意见必须原文进入 Supervisor 会话" + ); + reconcile_plan_gdd_approval_projections_locked(&root).expect("replay receipt projections"); + assert_eq!( + supervisor_messages().len(), + after_decision.len(), + "投影重放不得重复追加同一条审批意见" + ); + cleanup_fixture(root); + } + /// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。 /// /// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止 -- 2.52.0 From 92e571e1dcef3646829663742c7f8e52b52fae56 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 09:04:30 +0000 Subject: [PATCH 05/19] =?UTF-8?q?=E6=97=A0=E5=A4=B4=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=83=BD=E8=B7=91=E5=AE=A1=E6=89=B9=E7=9A=84=E4=BF=AE=E6=94=B9?= =?UTF-8?q?/=E9=80=80=E5=9B=9E=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA?= =?UTF-8?q?=E8=83=BD=E6=8A=95=20approve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-swarm-test-chat.mjs --plan` 的自动审批把动作写死成 approve,原注释让人 「要跑那两条分支就手工调 --plan-gdd-decide」。但那条路走不通:decide 要求 plan 根 run 仍是当前唯一 active 的,而它恰好是本进程持有的 CLI 子进程——CLI 一死, 再 decide 就是 PLAN_STALE_APPROVAL,实测过。于是 revise/reject 在无头侧没有任何 入口,e43133875 / 9e2648893 / b27ffce9b 三条修复都只能靠单测。 加 AGC_PLAN_GDD_DECISION + AGC_PLAN_GDD_COMMENT 两个环境变量,默认仍是 approve, 既有调用行为一字不变。原注释那条约束保留并落成断言:非 approve 时缺意见直接抛, 不给机器编一段的机会——意见必须由跑的人自己给。`runCapturedCargo` 顺带支持 stdin, 决定报告从写死的「已批准」改成打印实际动作。 用它跑通了一条完整真链路(两轮澄清 → 出稿 → 修改 → 返工 → v2): - session successor 与 gdd.v2.json 同一秒落盘,phase=awaiting_gdd_approval、 latestSubmittedRef=v2、activeRunId 已清空,不再 PLAN_SESSION_RECOVERY_REQUIRED - 意见原文进了 Supervisor 会话,也进了返工委派正文 - 标题「脉冲避航:微型核心危局」→「エネルギー・ランナー」,意见是「改成日文」 Co-Authored-By: Claude Opus 5 --- .../scripts/agent-swarm-test-chat.mjs | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 97d19625f..e170b07c6 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -143,7 +143,12 @@ export const usage = `用法: --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩 --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM - -h, --help 显示帮助`; + -h, --help 显示帮助 + +环境变量: + AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须 + 同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见 + AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`; function readOptionValue(args, index, option) { const value = args[index + 1]?.trim(); @@ -911,12 +916,15 @@ export function parseSettledSwarmTurnReport(output) { async function runCapturedCargo( cliArguments, setActiveChild, - { timeoutMs = null, label = 'Cargo 子命令' } = {}, + { timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {}, ) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], }); setActiveChild(child); + if (stdin !== null) { + child.stdin.end(stdin); + } let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); @@ -1007,7 +1015,9 @@ async function runTaskCargo( let planGddApprovalPromise = null; const startPlanGddApproval = () => { planGddApprovalStarted = true; - console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve'); + console.log( + `[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`, + ); planGddApprovalPromise = onPlanGddApprovalWait() .then((value) => { planGddApproval = value; @@ -1842,13 +1852,30 @@ export function parsePlanGddDecisionOutput(output) { ); } -// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张 -// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据 -// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。 +// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有 +// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把 +// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时 +// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着, +// 而它恰好是本进程持有的 CLI 子进程。 export function planGddAutoApprovalIsPending(state) { return Boolean(state?.pendingApproval); } +export function resolvePlanGddAutoDecision(env = process.env) { + const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim(); + if (!['approve', 'revise', 'reject'].includes(action)) { + throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject'); + } + const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim(); + if (action === 'approve') return { action, comment: null }; + if (!comment) { + throw new Error( + `${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`, + ); + } + return { action, comment }; +} + async function settlePlanGddApproval( projectPath, runtimeConfigPath, @@ -1875,18 +1902,21 @@ async function settlePlanGddApproval( if (!planGddAutoApprovalIsPending(before)) { return { decided: false, state: before }; } + const { action, comment } = resolvePlanGddAutoDecision(); const decision = await runCapturedCargo( [ '--config-dir', runtimeConfigPath, '--plan-gdd-decide', projectPath, - 'approve', + action, + ...(comment === null ? [] : ['--stdin']), ], setActiveChild, { timeoutMs: planGddApprovalTimeoutMs, label: 'Fast GDD 审批决定', + stdin: comment, }, ); if (decision.code !== 0 || decision.signal) { @@ -1926,7 +1956,7 @@ async function reportPlanGddApproval(approval) { return; } console.log( - ` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, + ` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, ); if (approval.recovered) { console.log( -- 2.52.0 From 15c0d6f0285170e7fcbb7af18b5a2b1963a82a9f Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 10:08:44 +0000 Subject: [PATCH 06/19] =?UTF-8?q?=E4=BF=AE=E9=80=9A=E6=BE=84=E6=B8=85?= =?UTF-8?q?=E4=BF=A1=E5=B0=81=E8=A2=AB=E6=94=B6=E6=9D=9F=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E6=8B=A6=E6=AD=BB=E7=9A=84=E6=B4=BB=E9=94=81=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=BB=99=E6=9C=80=E7=BB=88=E5=9B=9E=E5=A4=8D=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=8A=A0=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 策划子 Agent 每轮都在正确地吐 AGC_NEEDS_USER_INPUT_V1 信封(header 带主题、 三选项齐全),却被拒了 26 次,一条生产 run 空转 65 轮直到人工介入。 信封是通过 respond_to_user 交付的,走最终回复通道,于是撞上两道「计划必须全部 完成」的判据: - runtime_state.rs 收束链首位的 structured_plan_completion_blocker - runtime_protocol/finalization.rs 上「finalization 必须绑定已全部完成的计划 快照」这条 journal 持久不变量(第二道,是上面那条测试逼出来的) 而计划里「按用户决定收敛并提交」那一步在用户答之前永远不可能 completed:想问 用户就得先 respond_to_user,问不出去就答不了。对任何含「答完之后再做 X」步骤的 计划,这条判据都不可满足。 以前没炸靠两件偶然:策划子 Agent 不提交结构化计划(判据第一行就跳过),或者 提交了之后肯把没做的步骤标成 completed。翻了同机全部历史 run:4 条没提交计划、 2 条首版 3/4 改成 4/4 放行、这条首版 1/4 之后再没改过——就死了。 修法是判据豁免而不是代填步骤状态:澄清信封是本 run 挂起等用户答,剩余步骤归 用户答复后的 continuation run,把它们标成 completed 是伪造进度。豁免只放行 「计划未完成」这一件事,快照自身的结构合法性仍然逐项校验,其余 blocker 照常。 第二件:最终回复被拦下后 run 原地续跑重试,此前没有任何上限。空转闸只认裸 update_agent_plan,而这里模型每轮都在认真调 respond_to_user,没有任何计数器会 累加。新增 stale_finalization_rounds(持久 Runtime state,与 plan_submit_gdd_rejection_count / plan_update_idle_rounds 同一模式,重启不能把 活锁洗成新的无限 Provider 开销),上限 32——刻意留在两道软闸之上,让自愈路径 先有机会起作用。真实推进后清零。 守门两条: - 同一份未完成计划,普通交付收束必须被拦(blocker.tool=runtime.plan_update)、 澄清信封必须放行。两半都实测非空转:撤掉任一处豁免,对应那半立刻红。 - 兜底额度必须高于两道软闸,防止有人把它调到软闸以下抢跑自愈。 Co-Authored-By: Claude Opus 5 --- .../runtime_actions/response_stream_tests.rs | 65 ++++++++++++++ .../src/agent/runtime_driver/finalization.rs | 6 +- .../src/agent/runtime_driver/main_loop.rs | 85 +++++++++++++++++++ .../agent/runtime_protocol/finalization.rs | 6 ++ .../src-tauri/src/agent/runtime_state.rs | 9 +- .../src-tauri/src/delegation.rs | 16 +++- .../src-tauri/src/main.rs | 6 ++ 7 files changed, 187 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 477e54c39..54cefdf91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1847,3 +1847,68 @@ fn finalization_v4_binds_response_request_slot_into_identity() { .expect_err("tampered v4 responseRequestSlot must break finalization identity"); assert!(error.contains("幂等身份不匹配")); } + +/// 澄清信封退出不受结构化计划完成度判据约束,普通交付收束仍然受。 +/// +/// 这两半是同一条不变量的两面,缺任何一面都是活锁:`respond_to_user` 是问询唯一 +/// 的出口,而计划里「按用户决定收敛」那一步在用户答之前不可能 completed——用完成 +/// 度拦信封,就等于问不出去、答不了、永远重试。实测一条生产 run 因此空转 65 轮。 +#[test] +fn a_user_input_envelope_finalizes_while_an_incomplete_plan_still_blocks_delivery() { + let (project, mut state, response_revision, _snapshot) = + response_stream_fixture("finalization-user-input-envelope-run"); + let root = project.path(); + state.plan_revision = 1; + state.plan_explanation = "先问清核心闭环再出稿。".to_string(); + state.plan = vec!["发起首轮澄清".to_string(), "按用户决定出稿".to_string()]; + state.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "发起首轮澄清".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "按用户决定出稿".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + ]; + state.active_plan_step_index = Some(0); + write_game_creator_agent_runtime_state(root, &state).expect("write incomplete plan state"); + + let delivery = "已完成本轮交付。"; + let blocked = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + delivery, + response_revision, + &[], + ) + .expect("finalize plain delivery"); + match blocked { + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + assert_eq!(blocker.tool, "runtime.plan_update"); + } + other => panic!("计划未完成时普通交付收束必须被拦下,实际 {other:?}"), + } + + let envelope = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{{\"questions\":[{{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环\",\"question\":\"本局主要追求什么?\",\"options\":[{{\"label\":\"A · 推荐:抵达终点\",\"description\":\"沿路线避障抵达终点。\"}},{{\"label\":\"B · 计分生存\",\"description\":\"在加速路线里刷新分数。\"}},{{\"label\":\"需要原型验证\",\"description\":\"各做一个最小原型让目标玩家试玩。\"}}]}}]}}" + ); + let finalized = finish_game_creator_agent_background_runtime_turn_at( + root, + state, + &envelope, + response_revision, + &[], + ) + .expect("finalize clarification envelope"); + assert!( + !matches!(finalized, AgentBackgroundFinalizationOutcome::Stale(_)), + "澄清信封是挂起等用户答,不能被计划完成度判据拦下" + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 7cb0b3801..fc5d9579a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -393,7 +393,11 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 与 `finish_game_creator_agent_background_runtime_turn_with_checkpoint_at` + // 同一判据:澄清信封是挂起等用户答,不是交付收束,用计划完成度拦它会死锁。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(&journal.response)) + { Some(blocker) } else if let Some(blocker) = plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index d2ec3406f..4e690c44f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -255,6 +255,39 @@ fn finish_plan_submit_business_rejection_limit_at( /// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; +/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 +/// (补动作、补证据、重新规划),所以这里的额度比上面两个宽得多;它拦的是另一 +/// 类:模型根本无法满足的 blocker。那种情况下每一轮都是同一个请求换来同一个拒绝, +/// 没有任何计数器会累加——空转闸只认裸 `update_agent_plan`,而这里模型每轮都在 +/// 认真调 `respond_to_user`。实测一条生产 run 因此空转 65 轮直到人工介入。 +const AGENT_RUNTIME_STALE_FINALIZATION_LIMIT: u32 = 32; + +fn stale_finalization_limit_reached(rounds: u32) -> bool { + rounds >= AGENT_RUNTIME_STALE_FINALIZATION_LIMIT +} + +fn next_stale_finalization_rounds(current: u32) -> (u32, bool) { + let next = current.saturating_add(1); + (next, stale_finalization_limit_reached(next)) +} + +fn finish_stale_finalization_limit_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let error = format!( + "最终回复连续 {} 轮被收束门禁拦下,已停止自动续跑;请检查最后一次 blocker observation 后重新发起本轮任务。", + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT + ); + let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?; + let _ = append_game_creator_agent_background_task_failed_audit( + root, + &failed, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT, + ); + Ok(AgentBackgroundTaskOutcome::Finished) +} + /// 纯只读工具不算「推进」。 /// /// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 @@ -350,6 +383,22 @@ mod plan_update_idle_guard_threshold_tests { assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); } + /// 最终回复重试额度是 runaway 兜底,不是主判据:它必须留在两道软闸之上, + /// 让「摘掉 update_agent_plan 逼它调真动作」和空转闸先有机会自愈。调到软闸 + /// 以下,兜底就会抢在自愈之前把正常 run 打断。 + #[test] + fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() { + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT); + assert!(!stale_finalization_limit_reached( + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1 + )); + assert_eq!( + next_stale_finalization_rounds(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1), + (AGENT_RUNTIME_STALE_FINALIZATION_LIMIT, true) + ); + } + #[test] fn idle_limit_is_reached_only_at_the_configured_round() { assert!(!plan_update_idle_limit_reached(0)); @@ -459,6 +508,8 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str = "plan-submit-validation-retries-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str = "plan-update-idle-rounds-exhausted"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str = + "stale-finalization-rounds-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; @@ -531,6 +582,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; } + // 同上:计数随上一轮的 blocker 一起落盘,重启不能把第 N 次被拦洗成新一轮。 + if stale_finalization_limit_reached(runtime.stale_finalization_rounds) { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { return fail_game_creator_agent_background_context_at( &root, @@ -1578,6 +1643,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 runtime.plan_update_idle_rounds = 0; + // 同一个判据也给最终回复重试额度解锁:真实推进之后再被拦,是新的一 + // 轮尝试,不该继承上一段死循环的计数。 + runtime.stale_finalization_rounds = 0; } if plan.actions.is_empty() { // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 @@ -3879,6 +3947,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::Finished } Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { + let (stale_rounds, exhausted) = + next_stale_finalization_rounds(runtime.stale_finalization_rounds); + runtime.stale_finalization_rounds = stale_rounds; if let Err(error) = provider_handoff::remove_at(&root, &agent_id, &runtime.run_id) { return fail_game_creator_agent_background_context_at( &root, @@ -3908,6 +3979,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; + // 计数已随 blocker 一起落盘,这里才收束:让最后一次拒绝的 observation + // 留在续跑上下文里,失败原因指得回具体 blocker 而不是一句「超限」。 + if exhausted { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } AgentBackgroundTaskOutcome::ContinueSameRun { state: runtime, continuation, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index e4bf34b09..5b2f387d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -268,7 +268,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal &journal.plan_steps, journal.active_plan_step_index, )?; + // 澄清信封是本 run 挂起等用户答,不是交付收束:剩余步骤要等用户答复后的 + // continuation run 才做,在这里既不可能 completed,也不该被 Runtime 代填成 + // completed——那是伪造进度。只有真正宣告做完的最终回复才受这条不变量约束。 + // 这里放行的是「计划未完成」这一件事;快照自身的结构合法性仍由上面的 + // `validate_agent_runtime_structured_plan_snapshot` 逐项校验。 if journal.plan_revision > 0 + && !response_is_static_delegate_user_input_envelope(&journal.response) && (journal.active_plan_step_index.is_some() || journal .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 2f1829a55..2eaccbcbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1204,7 +1204,13 @@ where )); } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 澄清信封不是交付收束,是本 run 挂起等用户答。结构化计划完成度判据对它不可 + // 满足:想问用户就得先 respond_to_user,而计划里「按用户决定收敛并提交」那一 + // 步在用户答之前永远不可能 completed,于是问不出去、答不了、永远转。实测一条 + // 生产 run 因此空转 65 轮直到人工介入。其余判据仍然照常生效。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(response)) + { Some(blocker) } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) { @@ -1603,6 +1609,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( loop_iteration: 0, plan_submit_gdd_rejection_count: 0, plan_update_idle_rounds: 0, + stale_finalization_rounds: 0, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32, plan_revision: 0, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 67cba1ea8..f49737d6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -28,16 +28,24 @@ pub(crate) fn static_delegate_result_detail_max_chars( value: &str, default_max_chars: usize, ) -> usize { - if value - .trim_start() - .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) - { + if response_is_static_delegate_user_input_envelope(value) { STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS } else { default_max_chars } } +/// 这条回复是不是澄清信封,而不是一次交付收束。 +/// +/// 收束门禁按「任务是否做完」判据拦最终回复,而澄清信封恰恰相反:它是本 run +/// 就此挂起、把决定权交回用户,剩下的工作由用户答完之后的 continuation run 接着 +/// 做。用完成度判据去拦它,对任何含「答完之后再做 X」步骤的计划都不可满足。 +pub(crate) fn response_is_static_delegate_user_input_envelope(response: &str) -> bool { + response + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) +} + /// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 /// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 57d7d1595..249cb257c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -313,6 +313,12 @@ struct AgentRuntimeState { /// Provider spend. #[serde(default)] plan_update_idle_rounds: u32, + /// Consecutive final replies this run had refused by a completion blocker. + /// Runtime-owned durable state for the same reason as the two counters + /// above: a blocker the model cannot satisfy is a livelock, and a runner + /// restart must not launder it back into unbounded Provider spend. + #[serde(default)] + stale_finalization_rounds: u32, #[serde(default)] max_loop_iterations: u32, #[serde(default)] -- 2.52.0 From 0e84ea1ed90a778f79a217d01bb6a3a4bce8ba59 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 11:36:23 +0000 Subject: [PATCH 07/19] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=AD=96=E5=88=92?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8A=95=E5=BD=B1=E5=A4=B1=E8=B4=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=9A=84=20phase=20=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 planning-session-projection-failed 的写入与读取常量 补充稀有持久化 phase 的 Runtime task journal 回归测试 --- .../src/agent/runtime_driver/task_start.rs | 2 +- .../src-tauri/src/agent/runtime_state.rs | 7 +++++++ .../src-tauri/src/tests/runtime_state.rs | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9db5f7eac..3ff8e559a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -519,7 +519,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje let error = redact_agent_runtime_project_paths(root, &error, 500); let failed_task = AgentRuntimeTaskRecord { status: "failed".to_string(), - phase: "planning-session-projection-failed".to_string(), + phase: AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED.to_string(), current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(), terminal_detail: Some(error.clone()), error: Some(error.clone()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 2eaccbcbd..f0b760e52 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -4,6 +4,12 @@ static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-"; +/// task journal 只在**读**的时候校验 phase 白名单,写侧不校验。所以一个没登记的 +/// phase 落盘之后,整份 journal 从那一行起再也读不出来:`agent.run_status` 对该 +/// Agent 永久失败,父 run 只能瞎转到 needs-reconciliation。实测就是这么炸的。 +/// 让写方和白名单引用同一个常量,两边不可能再漂移。 +pub(crate) const AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED: &str = + "planning-session-projection-failed"; fn game_creator_agent_runtime_public_status_message_id( agent_id: &str, @@ -4089,6 +4095,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "completion-contract-failed" | "conversation-write-failed" | "public-status-write-failed" + | AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED | "parent-terminal" | "parent-link-missing" ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 1e3b140d1..b77477437 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -235,19 +235,33 @@ fn runtime_task_reader_accepts_rare_persisted_phases() { "failed", "public-status-write-failed", ); + // 写侧不校验 phase,只有读侧校验:白名单漏登记一个 phase,落盘之后整份 journal + // 从那一行起再也读不出来,`agent.run_status` 对该 Agent 永久失败。这里用写方 + // 引用的同一个常量,漏登记会在这条用例上先红。 + let planning_session_projection_failed = runtime_task_json_line( + "planning-session-projection-failed-run", + "failed", + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED, + ); fs::write( &path, - format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"), + format!( + "{brief}\n{parent_link_missing}\n{public_status_write_failed}\n{planning_session_projection_failed}\n" + ), ) .expect("write rare persisted task phases"); let records = read_all_game_creator_agent_runtime_tasks(&path) .expect("known persisted task phases must remain readable"); - assert_eq!(records.len(), 3); + assert_eq!(records.len(), 4); assert_eq!(records[0].phase, "brief"); assert_eq!(records[1].phase, "parent-link-missing"); assert_eq!(records[2].phase, "public-status-write-failed"); + assert_eq!( + records[3].phase, + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED + ); fs::remove_dir_all(root).ok(); } -- 2.52.0 From 41228b0cdab7cec623e48bf978e2eb2e7e6483a1 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 26 Aug 2026 13:18:12 +0000 Subject: [PATCH 08/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20GDD=20=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=E5=90=8E=E7=9A=84=E5=AE=A1=E6=89=B9=E5=8F=96=E8=AF=81?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 plan Supervisor 的 AwaitingAcceptanceEvidence 阶段与无副作用 durable 状态判定 在证据不足时收窄工具面,阻止重复 agent.delegate 并保留 file.read、acceptance_update、run_status 补充一条阶段工具面回归及项目排障记录 --- .../runtime_actions/tool_policy_snapshot.rs | 19 ++-- .../runtime_protocol/planning_approval.rs | 87 +++++++++++++++++++ .../agent/runtime_protocol/planning_submit.rs | 23 +++++ .../src-tauri/src/agent_native_tools.rs | 2 + .../shared-memory/decision-log.md | 7 ++ docs/project-memory/shared-memory/pitfalls.md | 7 ++ 6 files changed, 140 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 33653ffd5..fd21fa141 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -158,6 +158,9 @@ pub(crate) enum PlanRootSupervisorStage { GoalContract, /// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。 Delegate, + /// 最新 GDD 已提交但尚未完成当前根 Run 的 Acceptance Graph 取证:只能读取 + /// `game/fast_gdd.md`、更新验收图或重放状态,不能抢先创建重复策划 delivery。 + AwaitingAcceptanceEvidence, /// 已有委派:取证、返工与审批相关工具全部开放。 Delegated, } @@ -168,6 +171,9 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage( match stage { PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"], PlanRootSupervisorStage::Delegate => &["agent.delegate"], + PlanRootSupervisorStage::AwaitingAcceptanceEvidence => { + &["file.read", "agent.acceptance_update", "agent.run_status"] + } // 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。 PlanRootSupervisorStage::Delegated => &[ "file.read", @@ -194,6 +200,7 @@ mod plan_root_stage_tests { let union = [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] .into_iter() @@ -234,11 +241,13 @@ pub(crate) fn plan_root_supervisor_stage_at( let delegated = list_static_delegate_deliveries_at(root)? .into_iter() .any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id); - Ok(if delegated { - PlanRootSupervisorStage::Delegated - } else { - PlanRootSupervisorStage::Delegate - }) + if !delegated { + return Ok(PlanRootSupervisorStage::Delegate); + } + if plan_root_supervisor_acceptance_evidence_required_at(root, agent_id, run_id)? { + return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence); + } + Ok(PlanRootSupervisorStage::Delegated) } pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 564f7a6c0..1d0782003 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -338,6 +338,93 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt }) } +/// Return whether the plan-root Supervisor must collect the current GDD +/// acceptance evidence before it can dispatch another planning child. +/// +/// This is deliberately a read-only projection of the existing acceptance +/// gate. It does not create approval pending or mutate any planning sidecar; +/// the actual pending projection remains owned by +/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful +/// `agent.acceptance_update`. +pub(crate) fn plan_root_supervisor_acceptance_evidence_required_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if !crate::config::game_creator_planning_capability_enabled()? { + return Ok(false); + } + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { + return Ok(false); + } + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.supervisor-stage", + ) + .map_err(|error| format!("取得 plan Supervisor 阶段判定项目锁失败:{error}"))?; + plan_root_supervisor_acceptance_evidence_required_locked(root, run_id.trim()) +} + +fn plan_root_supervisor_acceptance_evidence_required_locked( + root: &Path, + run_id: &str, +) -> Result { + let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; + let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { + return Ok(false); + }; + let Some(global_latest) = gdds.last() else { + return Ok(false); + }; + if global_latest.gdd_id != gdd.gdd_id + || global_latest.version != gdd.version + || global_latest.fingerprint != gdd.fingerprint + { + return Ok(false); + } + validate_plan_gdd(gdd).map_err(|error| error.to_string())?; + + let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?; + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) + .map_err(|error| error.to_string())?; + if read_plan_gdd_approval_for_version_locked(root, gdd.version) + .map_err(|error| error.to_string())? + .is_some() + { + return Ok(false); + } + if let Some(pending) = + read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())? + { + if !pending_matches_gdd(&pending, gdd) { + return Err("plan Supervisor 阶段判定发现 approval pending identity 冲突".to_string()); + } + return Ok(false); + } + + let Some(session) = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? + else { + return Ok(false); + }; + if !plan_gdd_session_matches_submission(&session, gdd) { + return Ok(false); + } + let Some(delivery) = read_static_delegate_delivery_at(root, &gdd.delegation_id)? else { + return Ok(false); + }; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.terminal_status.as_deref() != Some("completed") + { + return Ok(false); + } + + Ok(matches!( + plan_fast_gdd_acceptance_status_at_locked(root, gdd)?, + PlanFastGddAcceptanceStatus::NeedsEvidence + )) +} + pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index f5dee5b6d..d8fd32755 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -4721,6 +4721,29 @@ mod tests { cleanup_fixture(root); } + #[test] + fn m1c2a_unapproved_gdd_requires_acceptance_evidence_before_delegate() { + let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); + + assert_eq!( + plan_root_supervisor_stage_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + assert_eq!( + agent_runtime_plan_root_supervisor_tools_for_stage( + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ), + &["file.read", "agent.acceptance_update", "agent.run_status"] + ); + + cleanup_fixture(root); + } + #[test] fn m1c2a_failed_acceptance_requires_claim_before_repair_dispatch() { let (root, gdd, root_runtime) = acceptance_gate_fixture(false); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 7ded0e67d..382d9765f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -2256,6 +2256,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); @@ -2290,6 +2291,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 134f60bc4..81b76e2c8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-08-26 Fast GDD 修订后先取证再允许再次委派 + +- **现象**:GDD v1 经用户选择“修改”后,策划子 Agent 正确提交 v2,但 plan 根 Supervisor 的 `Delegated` 阶段仍同时广告 `agent.delegate` 与审批前置工具;模型可能在 Acceptance Graph 重新取证前重复创建修订 delivery,随后被 `PLAN_PROVIDER_USAGE_DEFERRED` 拦停。 +- **决策**:plan 根阶段增加轻量的 `AwaitingAcceptanceEvidence` 状态。当前根最新 GDD 无 approval receipt/pending、session `latestSubmittedRef` 精确指向该提交、delivery 已由根认领且 Acceptance Graph 返回 `NeedsEvidence` 时,只广告 `file.read`、`agent.acceptance_update`、`agent.run_status`;只有用户真正对最新审批卡选择修改/退回后,才恢复 `agent.delegate`。 +- **边界**:不放宽 Provider usage 门禁,不重构 delegation/repair lineage,不自动生成证据或审批 pending;审批 pending 仍只由既有 acceptance gate 在 `agent.acceptance_update` 成功后创建。 +- **验证**:新增一条阶段工具面回归,并通过 15 条 M1C-2a acceptance gate 定向测试、plan root 原生工具目录测试、`cargo check --all-targets`、格式与 diff 检查。 + ## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放 - 背景:资源页已经补齐视频、角色动画、音效和背景音乐的 create/derive 能力,但 Direct Codex 只能准备标准美术包,无法查询已登记源资源或表达新增媒体意图。直接开放 Tauri invoke 会把项目路径、revision、operation、幂等键、登录态和事务权力交给模型。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index eed4970e8..61860d74c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,12 @@ # 踩坑与排障记录 +## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面 + +- **现象**:`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。 +- **原因**:自然语言 playbook 已规定“证据不足先取证、用户修改后才返工”,但阶段工具白名单没有把这条 durable 状态固化。 +- **处理**:阶段判定复用现有 acceptance gate 的 GDD/session/delivery/graph identity 检查,增加无副作用的 `AwaitingAcceptanceEvidence` 阶段;`PLAN_PROVIDER_USAGE_DEFERRED` 保持 fail-closed,不通过放宽 Provider 使用量门禁解决。 +- **排查顺序**:先看最新 `gdd.vN.json`、`session.latestSubmittedRef`、delivery 是否 `ClaimedByParent`,再看 Acceptance Graph 是否 `NeedsEvidence`;若仍可见 `agent.delegate`,优先检查 plan root 阶段快照,而不是修改 acceptance gate 或 Provider 门禁。 + ## 2026-08-15 把校验往链路前面挪,改的不是严格程度而是作用域 - 现象:CI 全量 5 条失败,看上去毫不相干(两条 Goal 续跑停在 `needs-reconciliation`、一条交接用例断言错误文案、一条恢复用例把不可读 state 的错误抛了出来、一条 Linux-only 用例错误码对不上),实际只有 3 个根因,且三者是**同一个形状**:新增或既有的检查被放在了链路更靠前的位置,于是它的语义作用域被悄悄放大或提前,而不是「变严」。 -- 2.52.0 From b767a6bc824988053b33ed2b4bb1328c7d6145e8 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 09:22:27 +0000 Subject: [PATCH 09/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AB=8B=E9=A1=B9?= =?UTF-8?q?=E7=AD=96=E5=88=92=E5=AE=A1=E6=89=B9=E4=BF=AE=E8=AE=A2=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 user_revision 来源并允许审批修订更新当前 GDD 决定快照 移除 planning submit 对历史 session 决定内容的逐项门禁与自动覆盖 修复历史审批回执污染当前 approval pending 恢复投影 更新策划 Prompt、技术方案、原型说明和必要回归测试 --- .../prompts/runtime/roles/project-planning.md | 5 +- .../src-tauri/src/agent/runtime_actions.rs | 4 +- .../provider_request_builders.rs | 44 ++- .../runtime_actions/provider_tool_plan.rs | 9 +- .../runtime_actions/tool_policy_snapshot.rs | 27 +- .../src/agent/runtime_driver/main_loop.rs | 34 +-- .../runtime_protocol/planning_approval.rs | 176 ++++++------ .../runtime_protocol/planning_storage.rs | 60 +++-- .../agent/runtime_protocol/planning_submit.rs | 254 +++++------------- .../src-tauri/src/agent_native_tools.rs | 6 +- apps/ai-game-creator-shell/src/app/types.ts | 6 +- .../shared-memory/decision-log.md | 15 ++ docs/project-memory/shared-memory/pitfalls.md | 14 + ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 19 +- 14 files changed, 324 insertions(+), 349 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index 3a2afcd41..347c83ae8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -19,9 +19,8 @@ ## 低幻觉与 GDD 约束 - 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 -- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 -- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 -- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 +- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。 +- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。 - `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 - 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index c33708993..34842fd5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -157,6 +157,6 @@ pub(crate) use tool_policy_snapshot::{ agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, agent_runtime_plan_root_supervisor_tools_for_stage, agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at, - PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS, - AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, + plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index fc2b440a2..0b0fa8f2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -153,8 +153,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too Ok(()) } -pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked( root: &Path, + project_lock: &ProjectWriteLock, agent_id: &str, session_id: &str, run_id: &str, @@ -172,6 +173,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( ), String, > { + if !project_lock.guards_project_root(root)? { + return Err("构建 Agent 工具计划缺少当前项目写锁".to_string()); + } let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; if planning_agent { validate_project_planning_child_binding_at(root, agent_id, run_id)?; @@ -527,7 +531,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if plan_root { - let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?; + let stage = plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)?; retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 @@ -661,6 +665,42 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )) } +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, + mcp_catalog: &GameCreatorMcpCatalog, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + root, + "runtime.provider_request.build.tool_plan", + )?; + build_game_creator_agent_background_tool_plan_request_locked( + root, + &_lock, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + mcp_catalog, + ) +} + pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 26bbc1bfd..5a727a65b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -251,8 +251,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -316,8 +317,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -370,8 +372,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at // that lock so a session successor cannot be used to re-label an // object assembled from an older session. if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - built_request = build_game_creator_agent_background_tool_plan_request( + built_request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index fd21fa141..62d9d9443 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -230,11 +230,19 @@ mod plan_root_stage_tests { } /// 只按 durable 事实判定阶段,不看 Provider 说了什么。 -pub(crate) fn plan_root_supervisor_stage_at( +/// +/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的 +/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层 +/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。 +pub(crate) fn plan_root_supervisor_stage_at_locked( root: &Path, + project_lock: &ProjectWriteLock, agent_id: &str, run_id: &str, ) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() { return Ok(PlanRootSupervisorStage::GoalContract); } @@ -244,12 +252,27 @@ pub(crate) fn plan_root_supervisor_stage_at( if !delegated { return Ok(PlanRootSupervisorStage::Delegate); } - if plan_root_supervisor_acceptance_evidence_required_at(root, agent_id, run_id)? { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)? + { return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence); } Ok(PlanRootSupervisorStage::Delegated) } +/// 供未持有项目写锁的调用方使用的阶段判定入口。 +pub(crate) fn plan_root_supervisor_stage_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.supervisor-stage", + )?; + plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id) +} + pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { agent_runtime_executable_tools() .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 4e690c44f..5dd85d02e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -208,15 +208,10 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } -/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input, -/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS -/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏, -/// 重交同一份 input 不可能成功,必须 reconcile。 +/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在 +/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。 fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { - matches!( - error.code(), - "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH" - ) + matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") } /// A malformed Fast GDD is useful feedback for the planning child, but it @@ -4221,27 +4216,4 @@ mod plan_gdd_blocker_projection_tests { "版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它" ); } - - /// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后 - /// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份 - /// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成 - /// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支 - /// 直接 needs-reconciliation,整条链路无产物收场。 - #[test] - fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() { - assert!( - plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 未逐项匹配当前 planning session 决策摘要" - )), - "台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束" - ); - assert!( - !plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_CAS_CONFLICT", - "planning session 已被其它动作推进" - )), - "真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度" - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 1d0782003..6e1a29391 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -345,30 +345,22 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt /// gate. It does not create approval pending or mutate any planning sidecar; /// the actual pending projection remains owned by /// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful -/// `agent.acceptance_update`. -pub(crate) fn plan_root_supervisor_acceptance_evidence_required_at( +/// `agent.acceptance_update`. The caller must hold the current project write +/// lock and pass that guard explicitly. +pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked( root: &Path, - agent_id: &str, + project_lock: &ProjectWriteLock, run_id: &str, ) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } if !crate::config::game_creator_planning_capability_enabled()? { return Ok(false); } - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() { + if run_id.trim().is_empty() { return Ok(false); } - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.supervisor-stage", - ) - .map_err(|error| format!("取得 plan Supervisor 阶段判定项目锁失败:{error}"))?; - plan_root_supervisor_acceptance_evidence_required_locked(root, run_id.trim()) -} - -fn plan_root_supervisor_acceptance_evidence_required_locked( - root: &Path, - run_id: &str, -) -> Result { let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { return Ok(false); @@ -1142,89 +1134,96 @@ fn project_receipt_locked( let pending_observation = approval_observation(receipt); let mut approval_pending_cleanup_eligible = false; - let approval_pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(value) => value, - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-read", - &error.to_string(), - ); - recovery_pending = true; - None - } - }; - match approval_pending { - Some(mut pending) => { - if !pending_identity_matches_gdd(&pending, receipt_gdd) { + // Approval pending is a singleton projection for the latest GDD, not a + // per-receipt projection. A historical receipt must still repair its own + // index/Markdown/audit/runtime anchors, but it must not compare the + // current pending card with its older GDD identity. After a revise/reject + // creates a newer GDD, that comparison is expected to differ. + if receipt.version == latest.version { + let approval_pending = match read_plan_gdd_approval_pending_locked(root) { + Ok(value) => value, + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-identity", - "approval pending 与 receipt GDD identity 不一致", + "approval-pending-read", + &error.to_string(), ); recovery_pending = true; - } else { - let expected_status = format!("observed_{}", receipt.action); - if !matches!(pending.status.as_str(), "awaiting_decision") - && pending.status != expected_status - { + None + } + }; + match approval_pending { + Some(mut pending) => { + if !pending_identity_matches_gdd(&pending, receipt_gdd) { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-status", - &format!( - "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", - pending.status - ), + "approval-pending-identity", + "approval pending 与 receipt GDD identity 不一致", ); recovery_pending = true; - // Do not remove a projection whose durable state belongs - // to another decision action. - approval_pending_cleanup_eligible = false; } else { - approval_pending_cleanup_eligible = true; - pending.status = format!("observed_{}", receipt.action); - pending.observation = Some(PlanGddApprovalObservationV1 { - tool: pending_observation.tool.clone(), - status: pending_observation.status.clone(), - summary: pending_observation.summary.clone(), - detail: pending_observation.detail.clone(), - }); - match plan_gdd_approval_pending_fingerprint(&pending) { - Ok(fingerprint) => { - pending.pending_fingerprint = fingerprint; - if let Err(error) = - write_plan_gdd_approval_pending_atomic_locked(&root, &pending) - { + let expected_status = format!("observed_{}", receipt.action); + if !matches!(pending.status.as_str(), "awaiting_decision") + && pending.status != expected_status + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-status", + &format!( + "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", + pending.status + ), + ); + recovery_pending = true; + // Do not remove a projection whose durable state belongs + // to another decision action. + approval_pending_cleanup_eligible = false; + } else { + approval_pending_cleanup_eligible = true; + pending.status = format!("observed_{}", receipt.action); + pending.observation = Some(PlanGddApprovalObservationV1 { + tool: pending_observation.tool.clone(), + status: pending_observation.status.clone(), + summary: pending_observation.summary.clone(), + detail: pending_observation.detail.clone(), + }); + match plan_gdd_approval_pending_fingerprint(&pending) { + Ok(fingerprint) => { + pending.pending_fingerprint = fingerprint; + if let Err(error) = + write_plan_gdd_approval_pending_atomic_locked(root, &pending) + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-write", + &error.to_string(), + ); + recovery_pending = true; + } + } + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-write", + "approval-pending-fingerprint", &error.to_string(), ); recovery_pending = true; } } - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-fingerprint", - &error.to_string(), - ); - recovery_pending = true; - } } } } + // The approval pending projection is allowed to be absent after the + // original submit anchors have durably consumed the terminal + // observation. The generic-anchor reconciliation below decides + // whether this is a normal post-consumption state or a recovery gap. + None => {} } - // The approval pending projection is allowed to be absent after the - // original submit anchors have durably consumed the terminal - // observation. The generic-anchor reconciliation below decides - // whether this is a normal post-consumption state or a recovery gap. - None => {} } let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { @@ -1280,11 +1279,24 @@ fn project_receipt_locked( // A replay may target an older receipt after a newer GDD has already been // submitted. The receipt still repairs its own audit/observation, but it // must not try to roll the current session or delivery lineage backwards. - let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) { - Ok(session) => session - .as_ref() - .and_then(|session| session.latest_submitted_ref.as_ref()) - .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), + let session_projection_eligible = match read_plan_session_with_recovery_locked(root) { + Ok(session) => session.as_ref().is_some_and(|session| { + let receipt_ref_matches = session + .latest_submitted_ref + .as_ref() + .is_some_and(|reference| reference == &receipt_plan_ref(receipt)); + let decision_ref_matches = + session.last_decision_ref.as_ref().is_some_and(|reference| { + reference.version == receipt.version + && reference.response_id == receipt.response_id + && reference.action == receipt.action + && reference.receipt_fingerprint == receipt.receipt_fingerprint + }); + decision_ref_matches + || (receipt_ref_matches + && session.phase == "awaiting_gdd_approval" + && session.active_run_id.is_none()) + }), Err(error) => { note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string()); recovery_pending = true; @@ -1292,7 +1304,7 @@ fn project_receipt_locked( } }; let mut session_projection_ready = false; - if receipt.version == latest.version || session_points_to_receipt { + if session_projection_eligible { if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string()); recovery_pending = true; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index a7f483afe..fba95f5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -440,7 +440,10 @@ fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { } fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "user_freeform" | "user_option" | "default") { + if matches!( + value, + "user_freeform" | "user_option" | "user_revision" | "default" + ) { Ok(()) } else { Err(invalid(format!("未知 answerSource:{value}"))) @@ -882,31 +885,38 @@ fn validate_decisions( if decision.round > 3 { return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); } - // round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威: - // answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认 - // 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、 - // 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证 - // (prototype_pending,并配同 id 的 prototypeValidationItems 项)。 - // - // 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、 - // 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为 - // prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending - // 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能 - // 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎: - // 那不是一个默认值,那是一个没人验证过的假设。 + if decision.answer_source == "user_revision" && decision.round != 0 { + return Err(invalid(format!( + "decisions[{index}] 的 user_revision 必须使用 round=0" + ))); + } + // round=0 不属于澄清轮:默认建议使用 default,审批修改使用 + // user_revision。两者都可以标记为 prototype_pending;用户明确修改的 + // 决定则可以标记 confirmed。 if decision.round == 0 && decision.id != "initial-request" { - if decision.answer_source != "default" { - return Err(invalid(format!( - "decisions[{index}] round=0 未经提问,answerSource 只能是 default" - ))); - } - if !matches!( - decision.state.as_str(), - "default_pending" | "prototype_pending" - ) { - return Err(invalid(format!( - "decisions[{index}] round=0 只能是 default_pending 或 prototype_pending" - ))); + match decision.answer_source.as_str() { + "default" + if matches!( + decision.state.as_str(), + "default_pending" | "prototype_pending" + ) => {} + "user_revision" + if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {} + "default" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending" + ))); + } + "user_revision" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending" + ))); + } + _ => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision" + ))); + } } } validate_text( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index d8fd32755..eff4c6dcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1100,108 +1100,6 @@ fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeCo .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) } -/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了 -/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么 -/// topic)。 -/// -/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。 -/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄; -/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了 -/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM -/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正 -/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。 -/// -/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。 -fn submit_decisions_respect_session_authority( - session: &PlanSessionV1, - input: &PlanSubmitGddInputV1, -) -> bool { - // 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条 - // 同 id 的 confirmed session 决定。 - let no_forged_confirmation = input.decisions.iter().all(|decision| { - if decision.state != "confirmed" || decision.answer_source == "default" { - return true; - } - session - .decisions_summary - .iter() - .any(|recorded| recorded.id == decision.id && recorded.state == "confirmed") - }); - // 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。 - // confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问 - // 时的唯一出口),但不能凭空消失。 - let no_dropped_authority = session.decisions_summary.iter().all(|recorded| { - let Some(decision) = input - .decisions - .iter() - .find(|decision| decision.id == recorded.id) - else { - return false; - }; - match recorded.state.as_str() { - "prototype_pending" => decision.state == "prototype_pending", - "confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"), - _ => true, - } - }); - // 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_ - // submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions` - // 的「逐项对应全部 prototype_pending 决定」双射约束兜底。 - let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| { - input - .prototype_validation_items - .iter() - .any(|item| item.id == recorded.id) - }); - no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items -} - -/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。 -/// -/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的 -/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其 -/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下 -/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。 -/// -/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里 -/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems` -/// (`build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放 -/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。 -fn apply_plan_session_authority_to_submit_input( - session: &PlanSessionV1, - input: &mut PlanSubmitGddInputV1, -) { - for decision in &mut input.decisions { - let Some(recorded) = session - .decisions_summary - .iter() - .find(|recorded| recorded.id == decision.id) - else { - continue; - }; - let carries_user_authority = match recorded.state.as_str() { - "confirmed" => decision.state == "confirmed", - "prototype_pending" => decision.state == "prototype_pending", - _ => false, - }; - if !carries_user_authority { - continue; - } - decision.answer_source = recorded.answer_source.clone(); - decision.round = recorded.round; - decision.answer_summary = recorded.answer_summary.clone(); - } - for item in &mut input.prototype_validation_items { - if let Some(recorded) = session - .prototype_validation_items - .iter() - .find(|recorded| recorded.id == item.id) - { - *item = recorded.clone(); - } - } -} - fn session_identity_matches_context( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, @@ -1247,7 +1145,6 @@ fn build_submit_session_successor( fn validate_current_session_cas( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, - input: &PlanSubmitGddInputV1, ) -> Result<(), PlanningStorageError> { if !session_identity_matches_context(session, context) { return Err(submit_error( @@ -1283,20 +1180,6 @@ fn validate_current_session_cas( "当前 planning session 仍有未决 GDD", )); } - // 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。 - // 真 CAS(revision 溢出、session 已被其它动作推进、Runtime source - // revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也 - // 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider - // input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。 - // 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected - // observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。 - // 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。 - if !submit_decisions_respect_session_authority(session, input) { - return Err(submit_error( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 的决定台账越过了 planning session 的用户权威", - )); - } if session.latest_delegation_id != context.delegation_id { return Err(submit_error( "PLAN_SOURCE_PROFILE_MISMATCH", @@ -1798,23 +1681,6 @@ pub(crate) fn execute_plan_submit_gdd( let session_read = read_plan_session_with_recovery_locked(root); let current_session = session_read.as_ref().ok().and_then(Option::as_ref); - // Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD - // 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd` - // 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化 - // 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor - // 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把 - // session 错误留给下面既有的分支处置。 - let normalized_input; - let input = match current_session { - Some(session) => { - let mut owned = input.clone(); - apply_plan_session_authority_to_submit_input(session, &mut owned); - normalized_input = owned; - &normalized_input - } - None => input, - }; - // First resolve the durable action identity. This branch intentionally // runs before pending/version checks: replay must be idempotent even when a // previous attempt already advanced the session or projections. @@ -1898,7 +1764,7 @@ pub(crate) fn execute_plan_submit_gdd( )); }; validate_plan_session(current_session)?; - validate_current_session_cas(current_session, context, input)?; + validate_current_session_cas(current_session, context)?; let version = chain .last() .map(|latest| latest.version.saturating_add(1)) @@ -2166,9 +2032,8 @@ mod tests { submit_fixture_from(valid_input()) } - /// 与 `submit_fixture` 同构,但由调用方提供 input:durable session 的 - /// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出 - /// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。 + /// 与 `submit_fixture` 同构,但由调用方提供 input,并用它初始化 session 的 + /// 当前决定快照,便于构造澄清后或审批修订后的提交场景。 fn submit_fixture_from( input: PlanSubmitGddInputV1, ) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { @@ -3438,38 +3303,36 @@ mod tests { } #[test] - fn submit_rejects_an_extra_non_default_decision_not_present_in_session() { + fn submit_allows_user_revision_decisions_outside_the_previous_session_snapshot() { let (root, context, mut input) = submit_fixture(); input.decisions.push(PlanSubmitDecision { id: "invented-confirmation".to_string(), - topic: "未提问决定".to_string(), + topic: "审批新增决定".to_string(), state: "confirmed".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "伪造为用户已确认".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户在审批意见中明确提出".to_string(), }); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a non-default decision outside the session prefix must fail"); - // 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类, - // 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。 - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + execute_plan_submit_gdd(&root, &context, &input) + .expect("a user revision may add a decision to the new snapshot"); + let chain = read_plan_gdd_chain(&root).expect("read chain"); + assert_eq!( + chain[0].decisions.last().unwrap().answer_source, + "user_revision" + ); cleanup_fixture(root); } - /// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条 - /// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。 + /// 新版本的决定快照由本次提交负责,旧 session 不再覆盖其内容。 #[test] - fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() { - // 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。 + fn a_rewritten_answer_summary_is_preserved_while_a_stale_session_is_still_a_cas_conflict() { let (root, context, mut input) = submit_fixture(); - let authoritative = input.decisions[0].answer_summary.clone(); input.decisions[0].answer_summary.push_str("(被改写)"); execute_plan_submit_gdd(&root, &context, &input) - .expect("a rewritten answer summary is overwritten, not rejected"); + .expect("the current submit snapshot owns its decision text"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!(chain[0].decisions[0].answer_summary, authoritative); + assert!(chain[0].decisions[0].answer_summary.ends_with("(被改写)")); cleanup_fixture(root); // 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。 @@ -3527,7 +3390,7 @@ mod tests { chain[0].decisions[1].topic, "重玩动力(用户实际回答的是这个)" ); - // 但答案原文仍然是 Runtime 的权威值。 + // 当前提交快照保留 Provider 生成的答案正文。 assert_eq!( chain[0].decisions[1].answer_summary, "不要那两个,我要玩家只能移动光源给守卫开路" @@ -3535,17 +3398,16 @@ mod tests { cleanup_fixture(root); } - /// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让 - /// 用户已经作出的决定从 GDD 里凭空消失。 + /// 修订可以删除、重写或重新定义旧决定;Runtime 不把旧 session 快照当内容门禁。 #[test] - fn a_confirmed_decision_may_be_downgraded_but_never_dropped() { + fn a_revision_may_downgrade_or_drop_an_obsolete_decision() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[1].state = "default_pending".to_string(); input.decisions[1].answer_source = "default".to_string(); input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string(); - execute_plan_submit_gdd(&root, &context, &input).expect("downgrade is the safe direction"); + execute_plan_submit_gdd(&root, &context, &input).expect("revision may change a decision"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - // 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖。 + // 新快照按 Provider 提交内容保存。 assert_eq!(chain[0].decisions[1].state, "default_pending"); assert_eq!( chain[0].decisions[1].answer_summary, @@ -3555,21 +3417,19 @@ mod tests { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions.remove(1); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("dropping a user decision must fail"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete decision"); cleanup_fixture(root); } - /// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西。 + /// 修订可以重新定义原型验证范围,但结构约束仍然有效。 #[test] fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[2].state = "confirmed".to_string(); input.prototype_validation_items.clear(); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a user-picked prototype validation must survive"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete prototype item"); cleanup_fixture(root); } @@ -3639,32 +3499,32 @@ mod tests { cleanup_fixture(root); } - /// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板, - /// 也不许挂上任何 user_* 来源。 + /// round=0 区分默认建议与审批修订来源;澄清来源仍不能伪装成 round=0。 #[test] - fn a_round_zero_decision_still_cannot_claim_any_user_authority() { + fn round_zero_accepts_user_revision_but_rejects_clarification_sources() { let mut confirmed = valid_input(); confirmed.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却声称已确认".to_string(), + topic: "审批修改的决定".to_string(), state: "confirmed".to_string(), - answer_source: "default".to_string(), + answer_source: "user_revision".to_string(), round: 0, - answer_summary: "伪造".to_string(), + answer_summary: "用户在审批意见中明确修改".to_string(), }); - validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed"); + validate_plan_submit_gdd_input(&confirmed) + .expect("user_revision may be confirmed at round=0"); let mut sourced = valid_input(); sourced.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却挂上用户来源".to_string(), + topic: "澄清来源不能伪装为 round=0".to_string(), state: "prototype_pending".to_string(), answer_source: "user_option".to_string(), round: 0, answer_summary: "伪造".to_string(), }); validate_plan_submit_gdd_input(&sourced) - .expect_err("round=0 may not carry a user answer source"); + .expect_err("round=0 may not carry a clarification answer source"); } #[test] @@ -4292,7 +4152,7 @@ mod tests { let mut next_context = context.clone(); next_context.source_session_revision = continuation.session_revision; next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); - validate_current_session_cas(&continuation, &next_context, &input) + validate_current_session_cas(&continuation, &next_context) .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 @@ -4304,9 +4164,25 @@ mod tests { next_context.action_fingerprint = "4".repeat(64); next_context.approval_request_id = Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); - let resubmit = - execute_plan_submit_gdd(&root, &next_context, &input).expect("continuation 提交 v2"); + let mut revised_input = input.clone(); + revised_input.game.title = "审批修订后的标题".to_string(); + revised_input.decisions.push(PlanSubmitDecision { + id: "approval-scope".to_string(), + topic: "审批修改范围".to_string(), + state: "confirmed".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户要求采用新的首版范围".to_string(), + }); + let resubmit = execute_plan_submit_gdd(&root, &next_context, &revised_input) + .expect("continuation 提交 v2"); assert_eq!(resubmit.gdd_ref.version, 2); + let chain = read_plan_gdd_chain(&root).expect("read revised GDD chain"); + assert_eq!(chain[1].game.title, "审批修订后的标题"); + assert_eq!( + chain[1].decisions.last().unwrap().answer_source, + "user_revision" + ); assert!( !resubmit.recovery_pending, "提交闸放行的 continuation,投影守卫也必须放行" @@ -4734,6 +4610,22 @@ mod tests { .expect("classify plan root stage"), PlanRootSupervisorStage::AwaitingAcceptanceEvidence ); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.plan-root-stage-locked", + ) + .expect("acquire plan root stage lock"); + assert_eq!( + plan_root_supervisor_stage_at_locked( + &root, + &project_lock, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify locked plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + drop(project_lock); assert_eq!( agent_runtime_plan_root_supervisor_tools_for_stage( PlanRootSupervisorStage::AwaitingAcceptanceEvidence diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 382d9765f..a6b8d274d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1153,8 +1153,8 @@ fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length: /// Strict provider-facing shape for `plan-submit-gdd-input.v1`. /// /// Runtime-injected identity, platform facts, version and fingerprint fields -/// deliberately do not appear here. The durable handler performs the -/// semantic/session equality checks after parsing this wire shape. +/// deliberately do not appear here. The durable handler performs the +/// structural, identity and CAS checks after parsing this wire shape. fn plan_submit_gdd_input_schema() -> Value { let decision_state = json!({ "type": "string", @@ -1162,7 +1162,7 @@ fn plan_submit_gdd_input_schema() -> Value { }); let answer_source = json!({ "type": "string", - "enum": ["user_freeform", "user_option", "default"] + "enum": ["user_freeform", "user_option", "user_revision", "default"] }); let pillar = json!({ "type": "object", diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index c816c7cfc..3ee8efe60 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -256,7 +256,11 @@ export interface PlanGddStateViewV1 { id: string; topic: string; state: 'confirmed' | 'default_pending' | 'prototype_pending'; - answerSource: 'user_option' | 'user_freeform' | 'default'; + answerSource: + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'default'; round: number; answerSummary: string; basis: null; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 81b76e2c8..419ae5ef3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6,6 +6,8 @@ - **决策**:plan 根阶段增加轻量的 `AwaitingAcceptanceEvidence` 状态。当前根最新 GDD 无 approval receipt/pending、session `latestSubmittedRef` 精确指向该提交、delivery 已由根认领且 Acceptance Graph 返回 `NeedsEvidence` 时,只广告 `file.read`、`agent.acceptance_update`、`agent.run_status`;只有用户真正对最新审批卡选择修改/退回后,才恢复 `agent.delegate`。 - **边界**:不放宽 Provider usage 门禁,不重构 delegation/repair lineage,不自动生成证据或审批 pending;审批 pending 仍只由既有 acceptance gate 在 `agent.acceptance_update` 成功后创建。 - **验证**:新增一条阶段工具面回归,并通过 15 条 M1C-2a acceptance gate 定向测试、plan root 原生工具目录测试、`cargo check --all-targets`、格式与 diff 检查。 +- **锁边界修正(2026-08-27)**:阶段判定拆为 `plan_root_supervisor_stage_at_locked` 与负责取得一次项目锁的外层入口;Provider tool-plan builder 已持有项目锁时直接复用 locked 入口。Acceptance Evidence 判据和阶段工具面不变,禁止在持锁调用链中再次获取 `.agent/project.lock`。 +- **回归验证**:planning submit 定向测试 68 passed、Provider request builder 定向测试 17 passed、Tauri `cargo check` 与 `cargo fmt --check` 通过。 ## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放 @@ -14735,6 +14737,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 安全:DirectProject 使用真实 `game/` writable root、`approvalPolicy=never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`;Codex 子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 与未审计浏览器/电脑控制继续关闭。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只留在 AGC 本地代理;前者仍走已配置上游,后者只走 OpenAI 官方 API,Codex 仅获得连接级随机代理令牌。无法安全代理的 OAuth `auth.json` 继续关闭原生 shell/unified exec。app-server 使用隔离 `CODEX_HOME`,shell 用 `shell_environment_policy` glob 排除 provider key、proxy、loopback bridge 和受控开关。 - 上下文:Direct 系统提示词只保留身份、cwd、边界和 Skill 索引;不再预注入项目源码快照、项目提示词或 Skill 正文。浏览器工具回传结构化事实,不强制固定三次整改循环;Codex 自行解释证据并决定是否继续。sandbox writableRoots 不提供 deny-read,`.agent`/`../assets` 的不可读约束需靠行为合同和真实 smoke 验证。 +## 2026-08-27 GDD 修改后历史 receipt 不得污染当前审批恢复 + +- 现象:GDD“修改”已成功生成下一版本且当前 pending 身份正确,但 hydrate 持续返回 `recoveryPending=true`,审批卡显示“审批状态正在恢复”。 +- 原因:恢复扫描会重放全部历史 approval receipt;旧版本 receipt 仍拿当前单例 approval pending 做 identity 比对。修改后当前 pending 已属于新版本,旧 receipt 的 identity 不同是正常状态,却被误记为投影缺口。 +- 决策:receipt 的 index、Markdown、audit、submit observation、session 等投影继续允许全量恢复;approval pending 只由 lineage 最新 GDD 的 receipt 读取、更新和清理。历史 receipt 不得检查或改写当前 pending,也不得因此提升 `recoveryPending`。 +- 验证:沿用现有审批恢复与 planning submit 定向测试;未新增独立测试,避免为非代表性 fixture 引入额外状态构造。 + +## 2026-08-27 审批修订以最新用户意见更新 GDD 决定快照 + +- `decisions` 表示当前 GDD 版本的决定快照,不再作为新提交必须逐项复制的 session 历史前缀。审批修订可以修改、推翻、删除或新增决定;Runtime 只校验结构、身份、CAS、版本和原型验证项双射,不做自然语言修改范围门禁。 +- 新增 `answerSource=user_revision`,用于标记来自审批修改意见的当前决定,按 `round=0` 记录;`default` 仍只表示未提问的默认建议,澄清来源仍使用 `user_option` / `user_freeform`。 +- planning Prompt 约束为:以当前 GDD 为基线,仅修改用户意见明确涉及的内容及保持内部一致性所必需的派生内容,未涉及内容保持不变;意见与旧决定冲突时以最新意见为准。 + ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 决策:`ui-prototype` 图片与 `UI` JSON 编辑资源保持两种正式类型。Agent 通过受控 `ui.workflow.run` 按 `prepare -> recognize -> status -> finalize` 创建页面资源、关联源图、持久化 UI State 和 manifest 阶段;`recognize` 直接复用 UI Editor 的 provider-backed 结构识别、多树合并与组件绑定命令,按 `reference-ready -> structure-ready -> merge-ready -> binding-ready` 逐阶段写入并推进项目 revision。页面可显式关联已登记图片/图标和字体,图片/图标按 5 项一批绑定,字体安全元数据进入绑定上下文且未知引用失败关闭。Runtime 回执携带 `revisionAdvanceCount`;Provider 未配置、请求失败、工具调用缺失、结果不匹配、未产出可渲染组件或仍有待审节点时保留最近真实阶段,禁止用 deterministic seed 冒充语义处理完成。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 61860d74c..1a56fe8e1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,13 @@ # 踩坑与排障记录 +## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁 + +- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`。 +- **原因**:`provider_tool_plan` 在构建请求前已持有 `.agent/project.lock`;`plan_root_supervisor_stage_at` 又调用会自行取锁的 Acceptance Evidence 包装入口。同一进程的文件锁不可重入,持锁调用被误判为外部竞争,等待约 10 秒后失败。问题与 Provider、代理端口或 GDD 内容无关。 +- **处理**:所有需要一致快照的状态读取保留在项目锁内;阶段判定提供明确的 `*_locked` 内部入口,外层入口仅供未持锁调用方取得一次锁。Provider builder 显式接收并校验当前锁后调用 locked 阶段判定,不引入可重入锁,也不移除 Acceptance Evidence 门禁。 +- **排查顺序**:先看失败 Run 的事件顺序是否为 `delegate receipt ready → 生成工具计划 → 阶段判定项目锁失败`,再检查调用方是否已持有 Provider plan project lock;不要因为错误文案包含“其他写操作”就先扩大锁等待或放宽 Provider usage。 +- **验证**:`cargo check`、`cargo fmt --check`、planning submit 68 passed、Provider request builder 17 passed;阶段测试同时覆盖未持锁包装入口和持锁 locked 入口。 + ## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面 - **现象**:`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。 @@ -4968,3 +4976,9 @@ - 严格图集崩溃补充:规范图和背景图的两文件 rollback 不覆盖严格图集事务已经整体修改的 `.agent/manifest.json`、私有回执、公开清单、主图集、四切片和切片清单。必须在严格调用前持久化 pending 及九项旧合同身份;重启恢复先对账底层严格事务,完整新合同直接收口完成,完整旧合同才补偿前两阶段,混合或漂移状态失败关闭。不要在严格提交成功后局部恢复前两张图。 - 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some` 或 `Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。 - 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。 + +## GDD 历史审批回执误触发当前恢复提示(2026-08-27) + +- 现象:修改 GDD 后新版本标题和内容已正确落盘,但审批卡一直显示“审批状态正在恢复”。 +- 原因:`approval pending` 是当前 lineage 最新 GDD 的单例投影;恢复扫描却让每个历史 receipt 都拿它做 identity 比对。旧 receipt 与新 pending 不同并不表示损坏。 +- 处理:历史 receipt 只修复自身投影;只有最新 GDD 的 receipt 才能校验、更新或清理当前 approval pending。不要在前端隐藏 `recoveryPending`,也不要取消最新版本的 identity fail-closed 检查。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index d03d81227..351cf76ef 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -171,7 +171,7 @@ D9/D10 描述的「manifest ready-task 调度器在 Supervisor 下游启动策 | hydrate read-model schema | `plan-gdd-state-view.v1` | | GDD 状态 | `draft \| ready_for_approval \| revision_requested \| approved \| rejected \| superseded` | | 单项决定状态 | `confirmed \| default_pending \| prototype_pending` | -| 回答来源 | `user_option \| user_freeform \| default` | +| 回答来源 | `user_option \| user_freeform \| user_revision \| default` | | 审计 recordType | `agent.runtime.plan.gdd_decided` | | planning typed 指纹文本 | `sha256-serde-json-v2:<64 位小写十六进制>` | | 现役 action/profile binding digest | `<64 位小写十六进制>`,无前缀 | @@ -400,7 +400,7 @@ Runtime 注入并强校验以下精确结构: - **轮次计数与上限**:本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,见第 23.5 节),上限 3;不再由 session 自行累加 `roundsUsed`。session 仍是 decisions 数组与 GDD 草稿内容的权威,但**不再是轮次状态机的权威**。 > **随之而来的合同影响 —— 2026-08-13 已全部收口。** 第 3 节注册表的 `plan-decision-checkpoint.v1` 与 request kind、第 8.6 节 `plan-session.v1` 的 `activeQuestion` / `roundsUsed` / `supersededCheckpointHandoffs`、第 9 节的 checkpoint domain 与 `supersededCheckpointProviderRequestIds`、第 12 节的 checkpoint stale 状态机、第 14 节与 activeQuestion 相关的恢复行,均已随本节重写一并删除或改写;第 9.1 节 golden vector 已按新 identity 重新生成(3857 bytes,`a59856de7e…`)。 -- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。 +- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。审批阶段的用户修改意见使用 `answerSource=user_revision`、`round=0`。 - session revision 1 由 Runtime 先写入固定 `initial-request` 决定:topic=`初始需求`、state=`confirmed`、answerSource=`user_freeform`、round=0、answerSummary 精确等于规范化后的 1~400 scalar 初始用户需求。Provider 不能改写或省略这条来源记录;超过上限的初始输入先要求用户收束,不能截断。 ### 5.2 决策卡 @@ -661,7 +661,7 @@ Provider 只能提交设计内容,不能提交或覆盖任何 Runtime 身份 该 input 及所有嵌套类型都使用 `deny_unknown_fields`;`game.platformFacts`、任意 `basis`、`projectId/gddId/version/submissionId/approvalRequestId`、action/run/session identity、时间与任何 fingerprint 一旦出现在 Provider input 中即返回 `PLAN_INVALID_REQUEST`。Runtime 在发出本轮 Provider request 前把当前 `sessionRevision/sessionFingerprint` 绑定进内部执行上下文,在项目锁内验证该 CAS 后,才把 project、GDD、版本、durable action、source/profile、session/run、时间、固定 `platformFacts`、全部 `basis:null` 与 fingerprint 注入 `plan-gdd.v1`。字段数量和文本限制按第 8.3 节对应 durable 字段执行。 -input 中必须逐项包含并精确等于 source session 的全部 `decisionsSummary` 和 `prototypeValidationItems`,不得改变决定的 id/topic/state/answerSource/round/answerSummary,也不得改变原型项正文或顺序;每个已提问决定因此具有可验证的 1~3 轮来源。额外 decision 只允许是未提问默认:`default_pending + default + round=0`,且不能为它伪造 prototype item。唯一允许的 `confirmed + user_freeform + round=0` 是 Runtime 创建的固定 `initial-request`,其 answerSummary 精确等于初始用户需求。直接出稿因此可以合法使用 `roundsUsed=0`,但仍至少提交该初始 decision。 +input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary` 和 `prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。唯一固定的 `confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`。 ### 8.3 `plan-gdd.v1` @@ -1150,15 +1150,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc 4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`;Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。 5. 除第 1~2 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。 -严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST`、`PLAN_SESSION_DECISIONS_MISMATCH`(2026-08-21 补,见下)或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 - -**(2026-08-21)台账逐项比对失败从 `PLAN_SESSION_CAS_CONFLICT` 拆出为 `PLAN_SESSION_DECISIONS_MISMATCH`,并纳入上述可重试分类。** 第 8.2 节「input 必须逐项包含并精确等于 source session 的 `decisionsSummary` 与 `prototypeValidationItems`」这条校验(实现为 `planning_submit.rs` 的 `session_decisions_match_input`)原先与三条真 CAS 判据(`sessionRevision` 溢出、session 已被其它动作推进、Runtime source revision/fingerprint 无效)共用一个错误码,因此被 `plan_submit_error_is_business_rejection` 漏掉,一次不匹配即 `needs-reconciliation` 硬阻断整个策划子 Agent。 - -两者性质本就不同,按本节自己的判据即可区分:真 CAS 说明 **durable 权威**已变或已坏,重交同一份 input 不可能成功;台账不匹配时权威完好,错的是**本次 Provider input**——策划子 Agent 把决策摘要抄漏、抄错,或多追加了一条非 `default_pending` 决定。后者正是本节划归「本次 Provider input / 候选 GDD」的那一类。 - -**不变量未放松**:不匹配照样拒绝、照样不产生任何事实,只是拒绝的后果从「叫人核对」变成「回灌 rejected observation 让 Provider 改稿」,仍受同一个 5 次 durable 预算约束,第 5 次照常终态失败。伪造用户确认(追加 `confirmed + user_option`)等第 8.2 节禁止的写法一条都没有变得可行。 - -**触发这次拆分的实测**:策划子 Agent 连续三次 submit 撞形状层(`PLAN_INVALID_REQUEST`),每次都按回灌的理由改对一部分——机制运转正常;第四次形状终于合法,随即撞上台账比对这一支,直接 `needs-reconciliation`,整条链路零产物收场。即**越接近提交成功越容易撞上不给重试的门**,这与「5 次预算让 Provider 自行收敛」的设计意图直接冲突。 +严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。 @@ -1167,7 +1159,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run;**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。 1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。 -2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 必须先逐项等于 source session 的完整决定前缀,前缀之后只允许追加 `state=default_pending + answerSource=default + round=0` 的未提问默认决定,任何伪造为用户已确认的额外决定都按 session CAS 冲突拒绝。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 +2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。 4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。 5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。 @@ -1447,7 +1439,6 @@ type PlanGddError = { | 'PLAN_UNSUPPORTED_KNOWLEDGE_BASIS' | 'PLAN_CORRUPT_AUTHORITY' | 'PLAN_SESSION_CAS_CONFLICT' - | 'PLAN_SESSION_DECISIONS_MISMATCH' | 'PLAN_SESSION_RECOVERY_REQUIRED' | 'PLAN_NEEDS_RECONCILIATION' | 'PLAN_DURABILITY_FAILED' -- 2.52.0 From a69e0bc68f59fa867415dd1581c9644842f2f4ef Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 10:32:50 +0000 Subject: [PATCH 10/19] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Provider=20=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E5=A4=B1=E8=B4=A5=E6=9C=AC=E5=9C=B0=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成功 handoff 失败时保存应用私有 Provider 响应诊断 在项目状态和 Agent DB 中记录本地诊断相对引用 补充 Runtime 技术方案与排障记录 增加私有诊断落盘和引用校验测试 --- .../runtime_protocol/provider_control.rs | 223 ++++++++++++++++++ .../agent/runtime_protocol/provider_retry.rs | 24 +- docs/project-memory/shared-memory/pitfalls.md | 6 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 + 4 files changed, 252 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 1e061353a..7d7ebc0f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -460,6 +460,148 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff ) } +const PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT: &str = + "diagnostics/provider-reconciliation"; +const PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES: usize = 1024 * 1024; + +/// Persist the raw successful Provider response only in the application +/// private data directory. Project state keeps the safe summary below; this +/// sidecar is diagnostic-only and is never consulted by recovery/retry logic. +pub(in crate::agent) fn write_provider_reconciliation_diagnostic_at( + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "Runtime config dir 未初始化,无法写入本地 Provider 诊断".to_string())?; + write_provider_reconciliation_diagnostic_in_dir( + &config_dir, + snapshot, + request_id, + response, + error, + ) +} + +fn write_provider_reconciliation_diagnostic_in_dir( + config_dir: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let project_key = format!("{:x}", Sha256::digest(snapshot.project_id.as_bytes())); + let request_key = format!("{:x}", Sha256::digest(request_id.as_bytes())); + let directory = config_dir + .join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + .join(&project_key); + fs::create_dir_all(&directory) + .map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?; + let relative_path = format!( + "{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json" + ); + let path = directory.join(format!("{request_key}.json")); + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本地 Provider 诊断目标必须是普通文件".to_string()); + } + return Ok(relative_path); + } + let diagnostic = serde_json::json!({ + "schemaVersion": "provider-reconciliation-diagnostic.v1", + "identity": { + "projectId": snapshot.project_id.clone(), + "agentId": snapshot.agent_id.clone(), + "taskId": snapshot.task_id.clone(), + "sessionId": snapshot.session_id.clone(), + "runId": snapshot.run_id.clone(), + "source": snapshot.source.clone(), + "requestKind": snapshot.request_kind.clone(), + "requestSlot": snapshot.request_slot.clone(), + "requestId": request_id, + "appliedSteerCursor": snapshot.applied_steer_cursor, + }, + "provider": { + "provider": format!("{:?}", response.provider), + "model": response.model.clone(), + "responseId": response.response_id.clone(), + "finishReason": response.finish_reason.clone(), + "usage": response.usage.clone(), + }, + "failure": { + "error": error, + }, + "response": { + "text": response.text.clone(), + "toolCalls": response.tool_calls.iter().map(|call| serde_json::json!({ + "id": call.id.clone(), + "name": call.name.clone(), + "arguments": call.arguments.clone(), + })).collect::>(), + }, + }); + let mut content = serde_json::to_string_pretty(&diagnostic) + .map_err(|error| format!("序列化本地 Provider 诊断失败:{error}"))?; + content.push('\n'); + if content.len() > PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES { + return Err(format!( + "本地 Provider 诊断超过 {PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES} 字节" + )); + } + let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?; + if let Err(error) = file + .write_all(content.as_bytes()) + .and_then(|_| file.sync_data()) + { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本地 Provider 诊断失败:{error}")); + } + drop(file); + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(format!("安装本地 Provider 诊断失败:{error}")); + } + Ok(relative_path) +} + +fn private_diagnostic_reference(error: &str) -> Option<&str> { + let reference = error.split_once(";localDiagnostic=")?.1.trim(); + let reference = reference.split(';').next()?.trim(); + if reference.starts_with(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + && reference + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/.-_".contains(character)) + { + Some(reference) + } else { + None + } +} + +fn attach_private_diagnostic_reference( + mut audit: serde_json::Value, + reference: Option, +) -> serde_json::Value { + if let (Some(reference), Some(audit)) = (reference, audit.as_object_mut()) { + audit.insert( + "localDiagnostic".to_string(), + serde_json::Value::String(reference), + ); + } + audit +} + #[cfg(test)] pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test( root: &Path, @@ -505,6 +647,8 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di { return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string()); } + let private_reference = + diagnostic.and_then(|(_, error)| private_diagnostic_reference(error).map(str::to_string)); let diagnostic = diagnostic.map(|(failure_kind, error)| { ( failure_kind, @@ -544,6 +688,11 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di detail }) .unwrap_or_else(|| format!("requestId={request_id}")); + let public_detail = if let Some(reference) = private_reference.as_deref() { + format!("{public_detail} · localDiagnostic={reference}") + } else { + public_detail + }; let event_detail = diagnostic .is_some() .then_some(public_detail.as_str()) @@ -625,6 +774,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di "requestSlot": snapshot.request_slot, }) }; + let audit = attach_private_diagnostic_reference(audit, private_reference); let _ = append_agent_db_record(root, audit); emit_game_creator_agent_runtime_update(root, &snapshot.agent_id); Ok(()) @@ -676,3 +826,76 @@ where ) .await } + +#[cfg(test)] +mod provider_reconciliation_diagnostic_tests { + use super::*; + + #[test] + fn private_diagnostic_keeps_raw_response_outside_project_state() { + let directory = tempfile::tempdir().expect("diagnostic directory"); + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: "project-1".to_string(), + agent_id: "project-planning".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + source: "agent-delegate".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "loop-1-repair-0".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: None, + }; + let response = platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "test-model".to_string(), + text: "C:\\private\\response".to_string(), + finish_reason: Some("completed".to_string()), + response_id: Some("response-1".to_string()), + usage: None, + tool_calls: vec![platform_llm::LlmToolCall { + id: "call-1".to_string(), + name: "runtime_tool_plan_submit_gdd".to_string(), + arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(), + }], + }; + let relative = write_provider_reconciliation_diagnostic_in_dir( + directory.path(), + &snapshot, + "provider-request-1", + &response, + "绝对路径 C:\\private\\error", + ) + .expect("write diagnostic"); + assert!(relative.starts_with("diagnostics/provider-reconciliation/")); + let persisted = + fs::read_to_string(directory.path().join(&relative)).expect("read diagnostic"); + let persisted: serde_json::Value = + serde_json::from_str(&persisted).expect("parse diagnostic"); + assert_eq!(persisted["response"]["text"], "C:\\private\\response"); + assert_eq!( + persisted["response"]["toolCalls"][0]["arguments"], + "{\"path\":\"C:\\\\private\\\\argument\"}" + ); + assert_eq!(persisted["failure"]["error"], "绝对路径 C:\\private\\error"); + } + + #[test] + fn private_diagnostic_reference_accepts_only_relative_reference() { + assert_eq!( + private_diagnostic_reference( + "失败;localDiagnostic=diagnostics/provider-reconciliation/p/r.json" + ), + Some("diagnostics/provider-reconciliation/p/r.json") + ); + assert_eq!( + private_diagnostic_reference("失败;localDiagnostic=C:\\secret.json"), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 92a6d3926..d33035874 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -1409,7 +1409,7 @@ where attempt_snapshot.clone(), provider_request, |provider_request_id, response| { - if persist_handoff { + let handoff_result = if persist_handoff { let response = canonicalize_handoff_response(response); provider_handoff::write_at( root, @@ -1418,7 +1418,8 @@ where attempt, provider_request_id, &response, - )?; + ) + .map(|_| ()) } else if persist_tool_plan_handoff { tool_plan_handoff::write_at( root, @@ -1427,7 +1428,24 @@ where attempt, provider_request_id, response, - )?; + ) + .map(|_| ()) + } else { + Ok(()) + }; + if let Err(error) = handoff_result { + let error = match write_provider_reconciliation_diagnostic_at( + &attempt_snapshot, + provider_request_id, + response, + &error, + ) { + Ok(path) => format!("{error};localDiagnostic={path}"), + Err(diagnostic_error) => { + format!("{error};localDiagnosticWriteFailed={diagnostic_error}") + } + }; + return Err(error); } Ok(()) }, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1a56fe8e1..07eb4b5c1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 2026-08-27 Provider 成功 handoff 失败时需要保留本地私有原始响应 + +- **现象**:Provider 已返回响应,但 tool-plan handoff 因绝对路径或其它内容安全校验失败,Runtime 只留下 `failureKind`、哈希和被压平的 JSON pointer;排障时无法确认实际工具名和完整 arguments。 +- **处理**:项目 `.agent`、Agent DB 和公共 event 继续只写安全摘要;额外在应用私有配置目录的 `diagnostics/provider-reconciliation//.json` 保存本次响应、tool calls 和校验错误,供本机人工排障。该文件不参与恢复/重试、不复制到项目、不进入 Git,单文件限制 1 MiB,写入失败不改变 reconciliation 语义。 +- **排查顺序**:先读 Runtime 状态里的 `localDiagnostic` 相对引用,再在应用私有目录读取诊断,核对 requestId、requestSlot、tool name 和失败 pointer;不要为了取得原文而放宽 handoff 的安全门。 + ## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁 - **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index d8d1bd9fd..6f1cd2e10 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1480,6 +1480,8 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是为 `requestKind=tool-plan` 增加独立私有账本 `.agent/runtime/tool-plan-handoffs//.json`,schema 固定为 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调保存已成功的 `repair-0..N` Provider 响应,每条绑定完整 retry identity、实际物理 `providerRequestId`、真实 request slot/attempt、Provider/model、去除 thinking 后的响应、thinking 归一化哈希/计数、完整 function call envelope、usage、响应指纹和创建时间。账本使用既有 `0600`、原子替换、父目录同步、`.previous` 恢复和写后完整回读;未知字段、乱序/缺口、重复 slot 冲突、超限、危险可执行路径、密钥或配置痕迹一律失败关闭。 +当成功响应因 handoff 校验失败而进入 `needs-reconciliation` 时,Runtime 额外在应用私有数据目录的 `diagnostics/provider-reconciliation//.json` 写入一次本地诊断。该诊断只服务人工排障,不参与恢复、重试或业务状态判断,可保留本次 Provider 响应、tool call arguments 和原始校验错误;项目 `.agent`、Agent DB、公共 event、CLI 与报告只保留安全摘要及该私有诊断的相对引用。诊断文件限制为 1 MiB,使用原子写入;应用配置目录不可用或诊断写入失败时,不改变既有 fail-closed reconciliation 语义。 + ### 提交、重放与所有权 - 每个 tool-plan 物理请求的顺序固定为:Provider 成功 -> tool-plan handoff 追加并回读 -> 同一实际 requestId lifecycle `completed` -> 解析/格式修复或动作预检。function arguments 只存在于私有 handoff 与后续 pending/action batch。protocol/repair 公共审计共同保存 `agentId/taskId/sessionId/runId/source/loopIteration/repairAttempt/requestSlot/responseFingerprint/providerRequestIdSha256/protocol`;protocol 只额外保存 `functionCallCount/callIdSha256s/functionNames/responseIdSha256/responseIdChars` 和既有 normalization 字段,其中 function names 必须由 catalog 绑定;repair 只额外保存 attempt/maxAttempts、协议错误/preview 哈希与字符数及 `callIdSha256/functionNameSha256`。公共 task、event、Agent DB、CLI 和报告不得保存原始 callId/callIds/responseId/providerRequestId。两类审计都在 Agent DB append 锁内按完整 Agent/task/Session/run/source/slot 身份做全历史 compare-and-append,不能以受限尾部读取替代幂等。 -- 2.52.0 From c5a0705eff38cb31a1b65ced1390e3973011f6b2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 11:17:31 +0000 Subject: [PATCH 11/19] =?UTF-8?q?=E6=94=B6=E7=AA=84=20Provider=20handoff?= =?UTF-8?q?=20=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按已知工具参数语义检查可执行路径字段 允许 plan.submit_gdd 的 GDD 与决定内容包含普通斜杠文本 移除未知字段绝对路径的过宽旧测试断言 补充路径校验边界排障记忆 --- .../tool_plan_handoff/content_validation.rs | 198 +++++++++++++++++- .../src-tauri/src/tool_plan_handoff/tests.rs | 24 --- docs/project-memory/shared-memory/pitfalls.md | 1 + 3 files changed, 197 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index ec8727915..1a3c5772a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -295,8 +295,19 @@ fn validate_tool_plan_json_absolute_path_inputs( value: &serde_json::Value, duplicate_safe_json: bool, ) -> Result<(), String> { + if tool_name == crate::agent_native_tools::PLAN_SUBMIT_GDD_FUNCTION_NAME { + // plan.submit_gdd 只承载 GDD/决定文本,没有可执行的文件路径字段。 + // 其中出现的斜杠、示例路径等属于用户内容,不应按工具路径扫描。 + return Ok(()); + } let mut findings = Vec::new(); - collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + if let Some(runtime_tool) = native_tool_for_handoff_function(tool_name) { + collect_native_tool_absolute_path_findings(root, runtime_tool, value, &mut findings); + } else { + // 未知工具和 legacy tool-plan 没有可信字段 schema,继续保守扫描整个 + // arguments payload,避免在无法解释参数语义时放宽路径边界。 + collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + } let Some(first) = findings.first() else { return Ok(()); }; @@ -313,6 +324,189 @@ fn validate_tool_plan_json_absolute_path_inputs( )) } +fn native_tool_for_handoff_function(tool_name: &str) -> Option<&'static str> { + crate::agent::agent_runtime_native_executable_tools() + .into_iter() + .find(|tool| { + crate::agent_native_tools::native_runtime_function_name(tool).as_deref() + == Some(tool_name) + }) +} + +fn collect_native_tool_absolute_path_findings( + root: &Path, + tool: &str, + arguments: &serde_json::Value, + findings: &mut Vec, +) { + let (input, input_pointer) = arguments + .get("input") + .map(|input| (input, "#/input")) + .unwrap_or((arguments, "#")); + match tool { + "project.search" | "file.list" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "file.read" | "file.write" | "file.patch" | "file.delete" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "project.patchset" => { + if let Some(changes) = input.get("changes").and_then(serde_json::Value::as_array) { + for (index, change) in changes.iter().enumerate() { + let pointer = format!("{input_pointer}/changes/{index}/path"); + collect_native_string_field(root, change, "path", &pointer, findings); + } + } + } + "project.git_commit" | "image.inspect" => { + collect_native_string_array_field( + root, + input, + "paths", + &format!("{input_pointer}/paths"), + findings, + ); + } + "command.exec" | "command.start" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + if let Some(args) = input.get("args").and_then(serde_json::Value::as_array) { + for (index, argument) in args.iter().enumerate() { + let pointer = format!("{input_pointer}/args/{index}"); + collect_native_string_value(root, argument, &pointer, findings); + } + } + } + "project.verify" => { + collect_native_string_field( + root, + input, + "expectedCommand", + &format!("{input_pointer}/expectedCommand"), + findings, + ); + } + "canvas.asset_generate" => { + collect_native_string_field( + root, + input, + "outputPath", + &format!("{input_pointer}/outputPath"), + findings, + ); + } + "ui.workflow.run" => { + if let Some(pages) = input.get("pages").and_then(serde_json::Value::as_array) { + for (index, page) in pages.iter().enumerate() { + let pointer = format!("{input_pointer}/pages/{index}/applicationPath"); + collect_native_string_field(root, page, "applicationPath", &pointer, findings); + } + } + } + "task.create" => { + collect_native_string_array_field( + root, + input, + "artifacts", + &format!("{input_pointer}/artifacts"), + findings, + ); + } + "agent.delegate" => { + collect_native_string_array_field( + root, + input, + "expectedArtifacts", + &format!("{input_pointer}/expectedArtifacts"), + findings, + ); + } + "agent.spawn_isolated" => { + if let Some(children) = input.get("children").and_then(serde_json::Value::as_array) { + for (index, child) in children.iter().enumerate() { + let pointer = format!("{input_pointer}/children/{index}/writeScopes"); + collect_native_string_array_field( + root, + child, + "writeScopes", + &pointer, + findings, + ); + } + } + } + // 其它原生工具的输入是文本、ID、枚举或计数,不承载文件路径。 + _ => {} + } +} + +fn collect_native_string_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(value) = object.get(key) { + collect_native_string_value(root, value, pointer, findings); + } +} + +fn collect_native_string_array_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(values) = object.get(key).and_then(serde_json::Value::as_array) { + for (index, value) in values.iter().enumerate() { + collect_native_string_value(root, value, &format!("{pointer}/{index}"), findings); + } + } +} + +fn collect_native_string_value( + root: &Path, + value: &serde_json::Value, + pointer: &str, + findings: &mut Vec, +) { + let Some(value) = value.as_str() else { + return; + }; + let Some(path_shape) = tool_plan_absolute_path_shape(value) else { + return; + }; + let relation_to_root = if matches!(path_shape, "exact-absolute" | "exact-platform-absolute") { + lexical_absolute_path_relation_to_root(root, value) + } else { + "not-applicable" + }; + findings.push(ToolPlanAbsolutePathFinding { + json_pointer: pointer.to_string(), + path_shape: path_shape.to_string(), + relation_to_root: relation_to_root.to_string(), + }); +} + fn collect_tool_plan_absolute_path_findings( root: &Path, value: &serde_json::Value, @@ -461,7 +655,7 @@ fn tool_plan_absolute_path_shape(value: &str) -> Option<&'static str> { fn tool_plan_function_class(tool_name: &str) -> String { if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { "legacy-tool-plan".to_string() - } else if let Some(tool) = super::ledger::runtime_tool_for_native_handoff_function(tool_name) { + } else if let Some(tool) = native_tool_for_handoff_function(tool_name) { format!("native:{tool}") } else if tool_name.starts_with("mcp_tool_") { "dynamic-mcp".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 5ea9ba07e..aee08077d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -797,30 +797,6 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "exact-absolute" }, ), - ( - serde_json::json!({ - "reason": "修复页面", - "opaqueProviderField": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), - ( - serde_json::json!({ - "reason": "修复页面", - "12345678901234567890": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), ] .into_iter() .enumerate() diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 07eb4b5c1..89bad121d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4196,6 +4196,7 @@ - 现象:Provider 已返回 HTTP 200 并计费,tool-plan lifecycle 却只有 `started`,handoff 账本停在上一 loop,Runtime 进入 `needs-reconciliation`;重启 Runner 或 `/resume` 后仍原样被屏障阻断。 - 原因:在解析 function arguments 之前,对整段 `response.text` 和序列化 arguments 统一执行 `.env`、`game-creator.config` 等字面标记扫描。安全叙述如“无需读取 `.env`”,或 `oldText / newText / content / patch` 中的普通源码字面量,会在真实路径和内容字段尚未区分时被误判。原始响应未成功交接时不会留下正文,因此现场只能结合 loop 边界和最小复现定位,不能把高概率分支冒充已恢复的原响应证据。 - 处理:计划叙述与规范源码内容字段只检查真实密钥 token 形状、凭据头标记和不安全控制字符;结构化敏感 JSON key、非内容字段的配置痕迹和绝对路径、真实 token、容量、thinking、身份、顺序及账本完整性继续失败关闭。成功 handoff 失败时只在 Runtime event/state 和 Agent DB 保存受控 `failureKind`、脱敏错误 SHA-256、字符数与 requestId,禁止保存正文、arguments、密钥和绝对路径。 +- 路径边界补充:绝对路径校验按已知工具的参数语义执行,只检查 `path`、`paths`、`cwd`、`outputPath`、`changes[*].path`、`pages[*].applicationPath`、产物范围以及命令 `args` / `expectedCommand` 等可能影响文件访问或执行的字段;`plan.submit_gdd` 的完整输入属于 GDD/决定内容,不做文件路径扫描。未知工具、动态 MCP 和无法解析的 JSON 继续整体失败关闭,不能用普通内容字段白名单替代可信 schema。 - 验证:必须同时覆盖 narrative 和 `oldText / newText / content / html / patch` 提及 `.env` / `game-creator.config` 可 round-trip,`path=.env.local` 与 `sk-...` 真实 token 仍拒绝,全部 handoff 回归通过;诊断审计必须断言不存在 `error / response / arguments` 原文。修复后的外部 Provider 重试仍需新起独立轮次,不能与故障轮或确定性回归拼接为 PASS。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs`。 -- 2.52.0 From b70cc79f800dddee6e270460effa1eedba4fd1f2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 11:51:51 +0000 Subject: [PATCH 12/19] =?UTF-8?q?=E6=8B=86=E5=88=86GDD=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E4=B8=8E=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5=E5=BF=99=E7=A2=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批决定按钮仅反映用户决定提交状态 恢复按钮仅反映策划状态同步状态 贯通总控视图与工作台的独立状态参数 --- apps/ai-game-creator-shell/src/App.tsx | 6 ++-- .../project-workspace/GddApprovalCard.tsx | 30 +++++++++++-------- .../ProjectSupervisorView.tsx | 5 +++- .../ProjectWorkspaceChatPane.tsx | 5 +++- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c0e70c4d1..12c159f35 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -10901,7 +10901,8 @@ export function App({ } workspaceStatus={workspaceStatus} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} @@ -10998,7 +10999,8 @@ export function App({ projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 0d9681e01..57abcada5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -34,7 +34,8 @@ function planGddMarkdownDisplayPath(projectPath: string) { type GddApprovalCardProps = { state: PlanGddStateViewV1 | null; - busy: boolean; + hydrateBusy: boolean; + decisionBusy: boolean; error: string | null; onRefresh: () => void; onDecision: ( @@ -76,7 +77,8 @@ export function PlanGddSurface({ state, active = false, projectPath, - busy, + hydrateBusy, + decisionBusy, error, onRefresh, onDecision, @@ -101,7 +103,8 @@ export function PlanGddSurface({ {showCard ? ( 审批状态正在恢复,请保持当前审批版本不变。 - ) : null} @@ -434,23 +438,23 @@ export function GddApprovalCard({
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 3dde6bd9e..1e22942f0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -61,6 +61,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { workspaceStatus: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -94,6 +95,7 @@ export function ProjectSupervisorView({ workspaceStatus, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -114,7 +116,8 @@ export function ProjectSupervisorView({ state={planGddState} active={isPlanningLaneRuntime(runtimePanelProps.runtime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index 0144ba194..f06471eb9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -182,6 +182,7 @@ type ProjectWorkspaceChatPaneProps = { projectSupervisorTransientReply: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -273,6 +274,7 @@ export function ProjectWorkspaceChatPane({ projectSupervisorTransientReply, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -369,7 +371,8 @@ export function ProjectWorkspaceChatPane({ state={planGddState} active={isPlanningLaneRuntime(projectSupervisorRuntime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} -- 2.52.0 From f35c86d99688b5cef16ef929a9bfea0dcf006731 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 12:01:29 +0000 Subject: [PATCH 13/19] =?UTF-8?q?=E8=B0=83=E6=95=B4GDD=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=90=8C=E6=AD=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将恢复文案改为中性的审批状态同步提示 保留同步期间的重复提交阻止与重试入口 同步更新现有界面断言与代码注释 --- apps/ai-game-creator-shell/src/App.tsx | 2 +- .../src/features/project-workspace/GddApprovalCard.tsx | 6 +++--- .../tests/appSurface/plan-gdd.suite.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 12c159f35..b71692d4b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -695,7 +695,7 @@ export function App({ } catch (error) { // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 - // `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。 + // `recoveryPending` 永远翻不成真、「重试同步」入口不渲染,卡内没有出路。 // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 await hydratePlanGddState(targetProjectPath); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 57abcada5..4b3541bd2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -421,9 +421,9 @@ export function GddApprovalCard({ {state.recoveryPending ? (
- 审批状态正在恢复,请保持当前审批版本不变。 + 审批状态正在同步,请不要重复提交当前版本。
) : null} @@ -491,7 +491,7 @@ export function GddApprovalCard({ /> {!canDecide ? (

- 审批状态正在恢复,暂时不能提交决定。已输入的内容会保留。 + 审批状态正在同步,暂时不能提交决定。已输入的内容会保留。

) : null}
diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index cb3e568a0..6a4ca9104 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -124,8 +124,8 @@ export function registerPlanGddApprovalTests() { hydrateCallsBeforeDecision, ); }); - // 重灌后「重试恢复」入口出现——这是 recoveryPending 下唯一被允许的动作。 - await screen.findByRole('button', { name: '重试恢复' }); + // 重灌后「重试同步」入口出现——这是 recoveryPending 下唯一被允许的动作。 + await screen.findByRole('button', { name: '重试同步' }); // 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null), // 两句顺序写反这条断言就红。 expect(screen.getByRole('alert').textContent).toContain( -- 2.52.0 From bca595a55446c769b49c3dfd80627929968f713b Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 13:41:57 +0000 Subject: [PATCH 14/19] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=97=A0=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=86=B3=E5=AE=9A=E7=9A=84=20user=5Frevision=20?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan.submit_gdd 新版本仅在 session 已有 revise/reject lastDecisionRef 时接受 user_revision 首次 collecting 伪造 user_revision 改为拒绝且不落 GDD 同步 Fast GDD 技术方案与 decision-log --- .../agent/runtime_protocol/planning_submit.rs | 42 +++++++++++++++---- .../shared-memory/decision-log.md | 8 ++++ ...方案】立项策划Agent(Fast GDD)-2026-08-10.md | 6 +-- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index eff4c6dcf..1cc9e8430 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1189,6 +1189,33 @@ fn validate_current_session_cas( Ok(()) } +/// `user_revision` 只证明审批意见,不证明澄清。首次 collecting、澄清续跑和提交前 +/// 质量返工的 session 都没有 revise/reject `lastDecisionRef`;合法续跑会把该引用 +/// 带到新的 collecting successor 上。replay 不走这里。 +fn validate_user_revision_requires_approval_decision( + session: &PlanSessionV1, + input: &PlanSubmitGddInputV1, +) -> Result<(), PlanningStorageError> { + if !input + .decisions + .iter() + .any(|decision| decision.answer_source == "user_revision") + { + return Ok(()); + } + if session + .last_decision_ref + .as_ref() + .is_some_and(|reference| matches!(reference.action.as_str(), "revise" | "reject")) + { + return Ok(()); + } + Err(submit_error( + "PLAN_INVALID_REQUEST", + "user_revision 只能用于当前 session 已有 revise/reject 审批决定的续跑提交", + )) +} + fn validate_durable_child_binding( root: &std::path::Path, context: &PlanSubmitGddRuntimeContext, @@ -1765,6 +1792,7 @@ pub(crate) fn execute_plan_submit_gdd( }; validate_plan_session(current_session)?; validate_current_session_cas(current_session, context)?; + validate_user_revision_requires_approval_decision(current_session, input)?; let version = chain .last() .map(|latest| latest.version.saturating_add(1)) @@ -3303,7 +3331,7 @@ mod tests { } #[test] - fn submit_allows_user_revision_decisions_outside_the_previous_session_snapshot() { + fn submit_rejects_user_revision_without_revise_or_reject_decision() { let (root, context, mut input) = submit_fixture(); input.decisions.push(PlanSubmitDecision { id: "invented-confirmation".to_string(), @@ -3314,13 +3342,11 @@ mod tests { answer_summary: "用户在审批意见中明确提出".to_string(), }); - execute_plan_submit_gdd(&root, &context, &input) - .expect("a user revision may add a decision to the new snapshot"); - let chain = read_plan_gdd_chain(&root).expect("read chain"); - assert_eq!( - chain[0].decisions.last().unwrap().answer_source, - "user_revision" - ); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("first collecting submit cannot forge user_revision"); + assert_eq!(error.code(), "PLAN_INVALID_REQUEST"); + assert!(error.to_string().contains("revise/reject")); + assert!(!root.join(".agent/planning/gdd.v1.json").exists()); cleanup_fixture(root); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5682da92f..16609f13b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` + +- 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 +- 决策:新版本 create 时,payload 含 `user_revision` 则当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;否则 `PLAN_INVALID_REQUEST`。同 `submissionId` replay 不重判。不恢复 session 前缀逐项相等,不把 `user_revision` 与审批意见正文对齐,也不在这次处理 `round≥1` 的 `user_option` 伪造。 +- 影响范围:`planning_submit.rs` 提交闸;Fast GDD 技术方案第 5.1 / 8.2 / 12 节。 +- 验证方式:首次 collecting 带 invented-confirmation 必须拒绝且不落 GDD;reject continuation 再交 `user_revision` 的 v2 仍成功。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 + ## 2026-08-27 SpacetimeDB 工具链统一升级到 2.8.3 - 背景:SpacetimeDB 2.8.0 引入 TypeScript submodule 与调度延迟观测,2.8.1 修复 v1 WebSocket 订阅移除死锁、TypeScript SDK `array` 读缓存别名和 Rust string 默认值支持,2.8.2 修复 table accessor 改名自动迁移,2.8.3 修复 scheduled function 从实际执行时间重排导致的长期漂移。仓库若继续锁定 2.7.0,会保留这些已知运行时与 SDK 问题。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 08fcdecc3..2717788e1 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -402,7 +402,7 @@ Runtime 注入并强校验以下精确结构: - **轮次计数与上限**:本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,见第 23.5 节),上限 3;不再由 session 自行累加 `roundsUsed`。session 仍是 decisions 数组与 GDD 草稿内容的权威,但**不再是轮次状态机的权威**。 > **随之而来的合同影响 —— 2026-08-13 已全部收口。** 第 3 节注册表的 `plan-decision-checkpoint.v1` 与 request kind、第 8.6 节 `plan-session.v1` 的 `activeQuestion` / `roundsUsed` / `supersededCheckpointHandoffs`、第 9 节的 checkpoint domain 与 `supersededCheckpointProviderRequestIds`、第 12 节的 checkpoint stale 状态机、第 14 节与 activeQuestion 相关的恢复行,均已随本节重写一并删除或改写;第 9.1 节 golden vector 已按新 identity 重新生成(3857 bytes,`a59856de7e…`)。 -- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。审批阶段的用户修改意见使用 `answerSource=user_revision`、`round=0`。 +- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。审批阶段的用户修改意见使用 `answerSource=user_revision`、`round=0`;Runtime 仅在 session 带 revise/reject 的 `lastDecisionRef` 时接受该来源。 - session revision 1 由 Runtime 先写入固定 `initial-request` 决定:topic=`初始需求`、state=`confirmed`、answerSource=`user_freeform`、round=0、answerSummary 精确等于规范化后的 1~400 scalar 初始用户需求。Provider 不能改写或省略这条来源记录;超过上限的初始输入先要求用户收束,不能截断。 ### 5.2 决策卡 @@ -663,7 +663,7 @@ Provider 只能提交设计内容,不能提交或覆盖任何 Runtime 身份 该 input 及所有嵌套类型都使用 `deny_unknown_fields`;`game.platformFacts`、任意 `basis`、`projectId/gddId/version/submissionId/approvalRequestId`、action/run/session identity、时间与任何 fingerprint 一旦出现在 Provider input 中即返回 `PLAN_INVALID_REQUEST`。Runtime 在发出本轮 Provider request 前把当前 `sessionRevision/sessionFingerprint` 绑定进内部执行上下文,在项目锁内验证该 CAS 后,才把 project、GDD、版本、durable action、source/profile、session/run、时间、固定 `platformFacts`、全部 `basis:null` 与 fingerprint 注入 `plan-gdd.v1`。字段数量和文本限制按第 8.3 节对应 durable 字段执行。 -input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary` 和 `prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。唯一固定的 `confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`。 +input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary` 和 `prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。payload 出现 `answerSource=user_revision` 时,当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;首次提交、澄清续跑和普通质量返工返回 `PLAN_INVALID_REQUEST`。该闸只作用于新版本 create,同 `submissionId` replay 不重判。唯一固定的 `confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`。 ### 8.3 `plan-gdd.v1` @@ -1161,7 +1161,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run;**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。 1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。 -2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 +2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。`user_revision` 还要求当前 session 已有 `lastDecisionRef.action ∈ {revise, reject}`;没有该引用时不得把未确认项标成审批修改。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。 4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。 5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。 -- 2.52.0 From 168a86c73f725e010fe8f0cbcf4455bc8a5cc7f1 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 14:01:21 +0000 Subject: [PATCH 15/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=81=E9=94=81=20Pr?= =?UTF-8?q?ovider=20=E6=9E=84=E5=BB=BA=E7=9A=84=E4=BA=8C=E6=AC=A1=E5=8F=96?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider tool-plan 持锁路径改用 locked 阶段判定入口 保留未持锁调用方的单次加锁包装入口 修复澄清回答恢复 continuation 因非重入项目锁卡死 --- .../provider_request_builders.rs | 71 ++++++++++++++++++- .../runtime_actions/provider_tool_plan.rs | 9 ++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index c1fdcb5e4..b61c02441 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -152,8 +152,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too Ok(()) } -pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( +fn build_game_creator_agent_background_tool_plan_request_at( root: &Path, + project_lock: Option<&ProjectWriteLock>, agent_id: &str, session_id: &str, run_id: &str, @@ -509,7 +510,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if plan_root { - let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?; + let stage = match project_lock { + Some(project_lock) => { + plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)? + } + None => plan_root_supervisor_stage_at(root, agent_id, run_id)?, + }; retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 @@ -643,6 +649,67 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )) } +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + None, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked( + root: &Path, + project_lock: &ProjectWriteLock, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + Some(project_lock), + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 017966869..b04408c07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -239,8 +239,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -303,8 +304,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -356,8 +358,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at // that lock so a session successor cannot be used to re-label an // object assembled from an older session. if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - built_request = build_game_creator_agent_background_tool_plan_request( + built_request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, -- 2.52.0 From 1bbecfd87b3bc17a845b2c4e1644126b5b893f51 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 28 Aug 2026 02:30:03 +0000 Subject: [PATCH 16/19] =?UTF-8?q?=E4=BF=AE=E5=A4=8DGDD=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9C=9F=E9=97=B4=E6=8F=90=E4=BA=A4=E7=AB=9E?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审批按钮在 hydrate 期间统一禁用 修改和退回弹窗提交复用同一忙碌状态门禁 提交处理函数同步阻止过期 pending 决定 --- .../features/project-workspace/GddApprovalCard.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 4b3541bd2..628f6f517 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -376,12 +376,13 @@ export function GddApprovalCard({ const canDecide = Boolean( pending && state.state === 'ready_for_approval' && !state.recoveryPending, ); + const decisionDisabled = !canDecide || decisionBusy || hydrateBusy; const submitComment = () => { // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, // 所以提交口要自己再判一次,不能只靠按钮 disabled。 - if (!canDecide || !commentAction || !comment.trim()) { + if (decisionDisabled || !commentAction || !comment.trim()) { return; } void onDecision(commentAction, comment.trim()) @@ -438,7 +439,7 @@ export function GddApprovalCard({