From 1e7ea51368f15ab421988efa64f841734308b2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 20:46:53 +0800 Subject: [PATCH 01/60] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E5=85=A5=E9=98=9F=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97=E5=BD=92?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E8=AE=BE=E8=AE=A1=E5=AE=9A=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR:命令=入队、放行归 Thread Manager、待发消息队列作为运行态事件归宿主 - 新增实施计划:五步落地顺序、标识符映射表、每步不变式与验收证据 - CONTEXT.md:接单/拒单词条替换为入队/入队失败/放行,新增待发消息队列与待发消息词条 - docs/README.md 补两条索引;decision-log.md 追加同日决策记录 --- CONTEXT.md | 30 ++-- docs/README.md | 2 + ...命令入队化与待发消息队列归宿主-2026-09-24.md | 139 +++++++++++++++++ .../shared-memory/decision-log.md | 33 ++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 144 ++++++++++++++++++ 5 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md create mode 100644 docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md diff --git a/CONTEXT.md b/CONTEXT.md index c22344ea0..00f7c178e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI ## 项目开发对话(DirectProject) **DirectProject 专属聊天模块**: -AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 +AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 _Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 **项目工作台布局**: @@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运 _Avoid_: 进度通知、快照轮询、第二套历史 **逻辑回合**: -Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 _Avoid_: Codex 原生回合、原生日志、进程生命周期 -**接单**: -把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 -_Avoid_: 发送成功、命令调用、接口返回 +**待发消息队列**: +Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。 +_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录 -**拒单**: -接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +**待发消息**: +已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。 +_Avoid_: 回合、在途回合、草稿 + +**入队**: +把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。 +_Avoid_: 发送成功、已经开跑、回合成立 + +**入队失败**: +入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。 _Avoid_: 回合失败、执行失败、失败事件 +**放行**: +Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。 +_Avoid_: 前端放行、定时轮询、放行失败 + **在途回合**: -界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 -_Avoid_: 运行中回合、乐观锁、发送队列 +界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、前端发送队列 **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 diff --git a/docs/README.md b/docs/README.md index 20a519f46..e183cb872 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,8 @@ - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 +- [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 +- [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md new file mode 100644 index 000000000..d4e741723 --- /dev/null +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -0,0 +1,139 @@ +# 【ADR】DirectProject命令入队化与待发消息队列归宿主 + +状态:已接受(2026-09-24 设计定稿;**尚未落地**,实施顺序与验收见 +[`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) + +## 背景 + +待发消息队列今天是纯前端状态:回合运行中用户再发送就进本地 FIFO +(`chat/components/DirectProjectComposer/chatComposerQueue.ts`),回合终态事件到达后由**恰好开着的那个窗口**放行队首 +(`useDirectProjectChatController.ts` 的完成计数 effect 与 `startTurn` 的 `finally`)。三个问题: + +1. 队列是这条对话里唯一没有宿主持有者的事实:另一个窗口、另一个订阅者,或只是离开工作台再回来,都看不到已经排了什么队。 +2. 放行落在"哪个窗口恰好开着"上。队列一旦共享(本 ADR 要做的),两个窗口都会去放行队首,必然双发。 +3. 命令边界今天写的是"接单":校验通过就登记占用、落盘用户条目、起整轮。但用户按下发送时想要的是"这条消息会被依次处理"—— + 命令的成功含义与用户意图之间隔着一次长度未定义的等待。 + +前置口径:`【ADR】DirectProject命令接单化-2026-09-23` 已把逻辑回合收归 Thread Manager,并在 §8 留了 TODO +「以后这条队列挪到 Rust 端,落点就是 Thread Manager 的接单动作」,同时把 Rust 端发送队列列进"明确不做"。 +本 ADR 就是那条 TODO 的收口,并顺带收掉两处已经没有现役价值的实现。 + +## 决策 + +### 1. 词表:入队 / 入队失败 / 放行 + +- 命令边界的成功与失败改叫 **入队 / 入队失败**;「接单」「拒单」两个词退役,不再出现在文档、注释、标识符与测试名里。 +- 旧「接单」在语义上的角色(这一轮真正成立的那一刻)改叫 **放行**。 +- 因此旧句「接单成立 ⇔ 事件流里有开始有结束」要改写成「**放行成立** ⇔ 事件流里有开始有结束」。这不是换词而是角色搬家: + 逐处改写时按角色判,不做字面替换(命令边界→入队/入队失败;回合成立→放行;检查归属→入队时的检查)。 + +### 2. 命令 = 入队 + +`chat_with_game_creator_direct_codex` 改义改名(建议 `enqueue_direct_codex_turn`),主体是今天"接单前"那条链**原样**跑完: +`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备;通过后**只入队**—— +追加 `queue.enqueued`,不登记占用、不落盘用户条目、不发回合事件、不起 codex。 +任何一步失败就是**入队失败**,走命令返回的 typed 载荷(`DirectTurnRejection` → `DirectTurnEnqueueFailure`),用户就在现场。 + +入队按 `clientTurnId` 幂等,判重范围是"在队 ∪ 正在跑的那一轮";重复入队返回同一次成功,不再是并发拒单。 + +### 3. 队列归 Thread Manager + +- 每个 thread(线程身份就是项目规范路径)一条 FIFO;本期只支持**按顺序追加**与**按身份移除**,不做重排、优先级、编辑。 +- 队列状态**就是事件列表本身**:`queue.enqueued` 在条目仍在队期间不可回收,离开队列(取消或放行)时转为可回收并被既有规则回收。 + 不新增第二张队列表;`subscribe` 的 live-set bootstrap 因此天然把当前队列交给新订阅者——这就是"加入者看到的那几条"。 +- 上限 5 只数**在队条目**(不数正在跑的那一轮),由 Rust 持有;满队时入队失败并给出既有提示文案。 + +### 4. 线上形状 + +- `queue.enqueued { clientTurnId, userItem, creationType?, at }`:`userItem` 是 canonical 用户条目,前端据此派生 chip 文案, + Rust 不渲染、不裁成展示形状。事件**不带** prompt——prompt 是入队检查的产物,只留在宿主的队列条目里。 +- `queue.removed { clientTurnId, reason }`:`reason` 是 ts-rs 导出的 **typed 枚举**(`cancelled | dispatched`),永不用字符串, + 形状与 `turn.completed{status, failure?}` 同构。 +- 两条事件与其它运行态事件同一条流、同一个 reducer。 + +### 5. 放行 = 旧「接单」的语义角色 + +Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登记占用 → 发 `turn.started` → 落盘用户条目 → 下发用户条目 → 起整轮, +并在同一临界区写 `queue.removed{ dispatched }`(chip 消失与气泡出现在同一批 consume 里,中间没有空窗)。 + +**放行不重跑任何检查,也不存在"放行失败"这种状态**:放行之后的一切失败都是**回合失败**,走既有 `turn.completed.failure` 通道; +不新增任何失败通道,也不为放行补拒单出口。 + +### 6. 放行的触发与监护顺序 + +- 唯一放行点:回合任务收尾之后的 `kick`(正常 / 失败 / 中止三条路径共用,外加一个 drop 守卫盖 panic),幂等,并且在临界区里原子认领队首。 +- 入队时也踢一脚:「队列非空 + 线程空闲」是合法状态,对应今天的"直接发送"。 +- 入队**不取** `DirectTaonierActiveInvocationGuard`:它必须整轮持有(它是这一轮的调用身份,付费美术、执行会话、MCP、校验、 + 上下文预取都靠它把工作归属到自己那一轮),入队若取它等于"回合运行中不能入队"。入队路径的并发由工程准备自身的项目写锁与队列兜。 + +### 7. 渲染侧 + +前端不再持有队列副本:chip 只由事件投影(入队被拒时只由命令返回值给反馈,保留既有提示文案与"不丢草稿"行为); +忙态只由事件投影加「队列非空」指示。排队消息在放行前**不写** `project.jsonl`,用户气泡仍然只来自宿主条目("落盘即放行"不变)。 + +### 8. 作用域与寿命 + +每项目一条、全进程共享:A 窗口排队 B 窗口可见可取消;切项目、离开工作台、关窗口都不影响队列继续放行;进程结束队列消失。 +**不做跨进程持久化**。 + +### 9. 顺带退役 + +- `--direct-codex-chat` 整个退役(解析、派发,以及只服务它的 `run_direct_game_creator_turn_at` 一对函数)。 + 它的历史用途只有一个:手工生产验证夹具 `scripts/direct-execution-production-fixture.mjs`(PR #439 引入,不在 CI、 + 没有 npm 脚本或 harness 注册、没有任何测试钉它,唯一硬依赖是进程退出码)。没有产品入口价值,也不该在入队化之后 + 成为第二条直接起回合的路径;夹具脚本一并退役。 +- CLI 一退,`DirectTurnError::TurnAlreadyRunning` 的两个生产点(调用身份守卫、占用登记)都没有调用方, + 它连同前端"同一轮消息仍在处理中"文案、专属分支与测试一起删。 +- `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 + (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:第二步让那五个读者 + 改读 Thread Manager 的活动回合身份,守卫连同它的测试与 60 秒卡死兜底一起删。删之前必须确认取消路径的无条件终态 + 仍然解得开"回合卡死"(那次兜底来自真实事故 `d833ca9d3`)。 + +## 备选方案与取舍 + +1. **状态归宿主、放行留前端**(再用一条 `claim` 命令做 CAS 认领):前端机器原样保留,但队列的继续推进依赖至少一个窗口活着, + 且"谁去认领"要靠竞态解决——正是要消掉的东西。作废。 +2. **入队只做形状校验、把前置检查留到放行**:会造出"放行期拒单"——一个没有调用方在等的失败,得为它发明新通道; + 而且"首个回合还在建工程、第二条已经排队"这类合法流程会被入队误拒。作废(检查跟着入队走)。 +3. **一条 `queue.changed{items:[…]}` 快照事件**代替两条细粒度事件:reducer 更傻,但每次变更搬全量、与既有细粒度事件风格不一致。不选。 +4. **`reason` 用字符串**:前端只能猜、无法穷举、无法在类型层穷尽分支。不选,用 typed 枚举。 +5. **保留 CLI**:省掉夹具改写,但等于为手工验证工具长期保留第二条直接起回合的路径,与"命令只有入队一个入口"冲突。不选。 + +## 影响与代价 + +- **入队即写盘**:引用 UI 设计文档的条目在 prompt 投影时会生成 `ui/generated-.js`(`ui_editor/persistence.rs`); + 从队列里取消不撤该文件。 +- **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 +- **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 +- **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 + `turnBusyRef`/`beginTurnBusy`/`endTurnBusy`、`queueSequenceRef`、`queuedTurns*`、排队条目那部分 `pendingRunAnalyticsRef` + 与 `chatComposerQueue.ts` 的队列逻辑整体退役;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞; + 埋点句柄改由宿主在放行时开。 +- **词表切换是一次性跨文档动作**:仓库里「接单/拒单」共 384 处,并非全属同一个域 + (`features/agent-runtime` 的"拒单文案"属另一个域,改成"请求被拒";`单测` 这类是假阳性)。 + 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 +- **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 + 它不在 CI,损失的是排障时的一次性手段,不是门禁。 +- **埋点**:attempt id 只用于宿主内存里的候选配对与前端结算(同 `session_id` 的 `Settle` 才提交), + 前端不再需要为排队条目提前持有它;平台会话代际翻转的 `discard` 判定仍是渲染侧职责,落地时逐条核对。 + +## 明确不做 + +- 不做跨进程持久化(不在入队时写 `project.jsonl`:那会在历史里留下一条永远不会跑的假消息,破坏"用户气泡只来自宿主条目")。 +- 不做重排、优先级、编辑待发条目;也不做"队列挂起 / 继续"状态——入队化之后队列里不存在会被拒的条目。 +- 不给放行新增失败通道,不为"放行不重跑检查"补兜底。 +- 不保留 CLI,也不为夹具保留别名或兼容入口。 + +## 落地时要同步的文档与注释 + +- `CONTEXT.md`:词条(待发消息队列 / 待发消息 / 入队 / 入队失败 / 放行 / 逻辑回合 / 在途回合)。 +- `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`:§8 的 TODO 与"明确不做:Rust 端发送队列"、§7 的"命令在飞"措辞、 + CLI 保持 await 的分工,全部改为入队化口径(正文保留,顶部加词表注记)。 +- `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件协议一节补两个队列事件与放行时序。 +- `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`:第 4 步的队列 TODO 指向本 ADR。 +- `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、 + `docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md`:删掉对 CLI 入口的承诺 + (最后那处现在写在"范围外(保留)"里,口径反转)。 +- `docs/project-memory/shared-memory/decision-log.md`:追加本决定,并修正"CLI 保持 await"那条。 +- 代码注释:`agent/direct_runtime/user_input.rs` 模块注释里的 CLI 分工一段删掉;`direct_turn_accept.rs`、 + `direct_thread_manager.rs`、`useDirectProjectChatController.ts` 的旧措辞与 TODO 一并改。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 53b75ec40..f8304d518 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9578,3 +9578,36 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 + +## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 + +- 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 + (这一轮真正成立的那一刻)改叫「放行」。所以旧句"接单成立 ⇔ 事件流里有开始有结束"改成"放行成立 ⇔ …", + 逐处按角色改、不做字面替换;`features/agent-runtime` 的"拒单文案"属另一个域,改"请求被拒"。 +- 决策(命令 = 入队):`chat_with_game_creator_direct_codex` 改义改名(暂定 `enqueue_direct_codex_turn`), + 把今天"接单前"那条检查链原样跑完(身份 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备), + 通过后只入队:不登记占用、不落盘用户条目、不发回合事件、不起 codex;失败走命令返回的 typed 载荷 + (`DirectTurnRejection` → `DirectTurnEnqueueFailure`)。入队按 `clientTurnId` 幂等(判重范围 = 在队 ∪ 在跑)。 +- 决策(队列归 Thread Manager):每项目一条 FIFO,只支持按顺序追加与按身份移除;队列的成员与顺序就是事件列表本身 + (`queue.enqueued` 在队期间不可回收、离开队列后可回收),`subscribe` 的 live-set bootstrap 因此天然把当前队列 + 交给中途加入的订阅者;上限 5 只数在队条目,由 Rust 持有。 +- 决策(线上形状):`queue.enqueued{clientTurnId,userItem,creationType?,at}` + `queue.removed{clientTurnId,reason}`, + `reason` 是 ts-rs 导出的 typed 枚举(`cancelled|dispatched`),不用字符串;事件不带 prompt—— + prompt 是入队检查的产物,只留在宿主的队列条目里。 +- 决策(放行):Thread Manager 在回合收口之后原子地「取队首 → 登记占用 → `turn.started` + `queue.removed{dispatched}`」, + 再落盘用户条目、下发用户条目、起整轮。**放行不重跑检查、不存在放行失败**,放行之后的一切失败都是回合失败, + 走既有 `turn.completed.failure`,不新增通道。kick 点 = 回合任务收尾(含 drop 守卫盖 panic)+ 中止路径 + 入队之后, + 幂等且在临界区里认领队首;入队不取 `DirectTaonierActiveInvocationGuard`(它必须整轮持有,是这一轮的调用身份)。 +- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿"); + 埋点句柄改由宿主在放行时开。 +- 决策(顺带退役):`--direct-codex-chat` 整个退役——它唯一的实际消费者是手工夹具 + `scripts/direct-execution-production-fixture.mjs`(PR #439 引入、不在 CI、无 npm/harness 注册、无测试钉它), + 夹具一并退役;`TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"分支随之删除。 + `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, + CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 + (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 +- 代价:入队即写盘(引用 UI 设计文档的条目会生成 `ui/generated-.js`,取消不撤);检查是时间点事实、 + 放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化; + 失去"真实二进制驱动执行层生产验证"这条手工路径。 +- 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、 + `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md new file mode 100644 index 000000000..150ef125a --- /dev/null +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -0,0 +1,144 @@ +# DirectProject 命令入队化与待发消息队列归宿主实施计划 + +更新时间:`2026-09-24` + +状态:**待实施**(设计已定稿,尚未开工) + +设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 +本文件只排实施顺序、不变式与验收,不重复设计理由。 + +## 第 0 步:词表切换(与代码同批,不单独提交) + +「接单 / 拒单」退役,改成「入队 / 入队失败 / 放行」。逐处按角色改,**不做字面替换**: + +| 旧写法 | 新写法 | +| --- | --- | +| 命令的成功 / 失败(接单 / 拒单) | 入队 / 入队失败 | +| 这一轮真正成立的那一刻(接单) | 放行 | +| 接单前的检查 | 入队时的检查 | +| 接单成立 ⇔ 事件流里有开始有结束 | **放行成立** ⇔ 事件流里有开始有结束 | +| 接单后的失败都是回合失败 | **放行后**的失败都是回合失败 | + +三类出现点必须分开处理(全仓 384 处): + +1. DirectProject 域(文档、注释、标识符、测试名):按上表改。主要落点 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、 + `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、 + `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`、`CONTEXT.md`、 + `agent/direct_runtime/user_input.rs`、`agent/direct_turn_accept.rs`、`agent/direct_turn_error.rs`、`agent/direct_thread_manager.rs`、 + `chat/controller/useDirectProjectChatController.ts`、`chat/conversation/directTurnPresentation.ts`、`tests/appSurface/chat-composer.suite.ts`。 +2. `features/agent-runtime` 域的「拒单文案」(`model.ts`、`tests/agentRuntimeModel.test.ts`):那里没有队列, + 改成「请求被拒 / 拒绝」,不要写成「入队失败」。 +3. 假阳性:`单测` 这类词不动(如 `server-rs/crates/api-server/src/editor_project.rs`)。 + +标识符同批改(映射表): + +| 旧 | 新 | +| --- | --- | +| `chat_with_game_creator_direct_codex` | `enqueue_direct_codex_turn` | +| `DirectTurnRejection`(ts-rs 导出) | `DirectTurnEnqueueFailure` | +| 前端 `readDirectTurnRejection` / `directTurnRejectionNotice*` | `readDirectTurnEnqueueFailure` / `directTurnEnqueueFailureNotice*` | +| Rust `direct_turn_rejection` | `direct_turn_enqueue_failure` | +| `DirectTurnReservation::accept` | `DirectTurnReservation::start` | +| `accept_direct_thread_turn` / `DirectThreadManager::accept_turn` | `start_direct_thread_turn` / `start_turn` | +| `agent/direct_turn_accept.rs` | `agent/direct_turn_dispatch.rs` | +| `DirectTurnError::TurnAlreadyRunning` | 删除(第 4 步判据确认零调用方后) | + +两份已接受的 ADR 保留正文与文件名,只在顶部加一行词表注记,避免正文里的旧词变成假命题。 + +验收:`rg -n "接单|拒单"` 在 DirectProject 域与 `agent-runtime` 域均为 0;生成绑定重跑(`cargo test export_bindings`)后 +`git diff` 只剩映射表内的改动。 + +## 第 1 步:命令 = 入队(Rust) + +改动点: + +- `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 + **入队半**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。**放行半**(第 2 步)从占用登记起。 +- 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 + `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 +- `agent/direct_thread_manager.rs`: + - `MAX_PENDING_DIRECT_TURNS = 5` 落在 Rust,只数在队条目;满队 → typed 入队失败。 + - 队列的成员与顺序**就是事件列表本身**:宿主侧产物挂在对应的 `queue.enqueued` 事件上(`StoredEvent` 增加一个非序列化的 + 可选产物字段),不另建队列表。 + - `observe_event`:`queue.enqueued` 在队期间**不可回收**;`queue.removed` 可回收,并把同 `clientTurnId` 的 enqueued 标记为可回收。 + `is_bootstrap_event` 不改——live-set bootstrap 因此自动把当前队列交给新订阅者。 + - 入队幂等:判重范围「在队 ∪ 正在跑的那一轮」,重复入队返回同一次成功。 + - `remove_direct_project_pending_turn(project_path, client_turn_id)`:typed 结果枚举 `Removed | AlreadyDispatched | NotFound`, + 在临界区里判「是否仍未被认领」。 +- 线上形状:`DirectThreadEvent::QueueEnqueued { client_turn_id, user_item, creation_type?, at }`(`queue.enqueued`)、 + `QueueRemoved { client_turn_id, reason: DirectQueueRemovalReason }`(`queue.removed`), + `DirectQueueRemovalReason` 是 ts-rs 导出的 typed 枚举 `cancelled | dispatched`。 + +不变式:入队不登记占用、不落盘、不发回合事件、不起 codex;入队失败不写用户条目、不产生事件。 + +## 第 2 步:放行与 kick + +- `start_direct_thread_turn`:同一个临界区里「取队首 → 占用登记 → `turn.started` + `queue.removed{dispatched}`」; + 之后落盘用户条目 → 下发用户条目 → spawn 整轮(顺序与今天的接单后半段一致,`accept` 必须早于落盘与 `turn/start`)。 +- `kick_direct_queue_dispatch(thread_id)`:幂等;在临界区里判「无占用 + 队首存在 + 未被认领」,认领后 spawn 放行任务。 + 调用点三个:回合任务收尾(正常 / 失败共用)、中止路径、入队之后。 +- panic 兜底:回合任务里的一个 drop 守卫负责踢一脚,保证任务 panic 或 future 被丢弃时队列不会永久停住。 +- 入队不取 `DirectTaonierActiveInvocationGuard`;该守卫继续由整轮持有。 +- 不变式:放行不重跑检查、没有放行失败;放行之后的一切失败都走 `turn.completed.failure`。 + +验收(Rust 单测):放行原子性(`queue.removed{dispatched}` 与 `turn.started` 同批、无中间窗口); +kick 幂等(并发两次只认领一次);队首在放行后被移除、remove 对已放行条目返回 `AlreadyDispatched`; +回合失败 / 中止后队列继续放行下一条;任务 panic 后队列仍能继续;多订阅者游标各自独立时 bootstrap 仍重建完整队列。 + +## 第 3 步:前端收口 + +退役: + +- `chatComposerQueue.ts` 的 `enqueueChatTurn` / `dequeueChatTurn` / `removeQueuedChatTurn` / `isChatTurnQueueFull` / + `MAX_QUEUED_CHAT_TURNS`(提示文案 `chatQueueFullNotice` 保留,改由 Rust 的 typed 入队失败驱动)。 +- controller 的 `queuedTurns` / `queuedTurnsRef` / `queueSequenceRef` / `completionPendingRef` / `handledCompletedTurnCountRef` / + `busyBaselineTurnCountRef` / `dispatchNextQueuedTurn` / `beginTurnBusy` / `endTurnBusy` / `turnBusyRef` 与排序用的计数 effect。 +- 排队条目那部分 `pendingRunAnalyticsRef` 与 `beginDirectRunAnalytics` 的调用时序(埋点句柄改由宿主在放行时开)。 + +保留与改写: + +- chip 由运行态事件的投影驱动(新增 pending 列表投影与两条队列事件的 reducer 分支); + chip 文案仍用现成的派生(`directCodexContentToPromptText` + `resourceLabelResolver`),只是输入换成事件里的 `userItem`。 +- 取消 chip 改调 `remove_direct_project_pending_turn`;入队失败只由命令返回值驱动提示(保留"不丢草稿"行为)。 +- 忙态 = 事件投影 + 「队列非空」指示;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞。 +- 写权限门 `ensureConversationWriteAllowed` 留在入队之前(确认框必须在用户在场时弹)。 + +验收:`tests/directThreadChat.test.ts` 补队列事件投影用例;`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / +放行三组用例改成新语义;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 + +## 第 4 步:CLI 与夹具退役 + +- `cli.rs`:删 `CliCommand::DirectCodexChat` 变体、`project_path_mut` 分支(`cli.rs:181`)与派发分支(`cli.rs:899-935`)。 +- `agent/direct_runtime/mod.rs`:删 `run_direct_game_creator_turn_at` 与 `run_direct_game_creator_turn_at_with_creation_type` + (各自只有彼此与 CLI 一个调用方)。 +- 删 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(CLI 的唯一消费者)。 +- 删 `DirectTurnError::TurnAlreadyRunning`(两个生产点都没了)与前端"同一轮消息仍在处理中"文案、专属分支、 + `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- 文档同步:09-22 里程碑把 `--direct-codex-chat` 从"范围外(保留)"改成退役项;两份 Direct 技术方案的 CLI 承诺删掉; + 09-23 ADR 与 `decision-log.md` 里"CLI 保持 await"的口径改掉。 + +验收:`rg -n -- "--direct-codex-chat"` 与 `rg -n "run_direct_game_creator_turn_at"` 零命中; +`cargo check --tests` 无新增 `dead_code` 告警;`check-config.mjs` 与 `.gitea/workflows/project-ci.yml` 不受影响(已核实无引用)。 + +## 第 5 步:守卫清理(CLI 退役之后) + +- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成、 + `agent/direct_validation.rs`、`agent/direct_project_context.rs`)改读 Thread Manager 的活动回合身份 + (新增一个按项目路径取 `active_turn.turn_id` 的只读入口)。 +- 删 `DirectTaonierActiveInvocationGuard` 与 `release_stale_direct_taonier_active_invocation` 及其测试 + (`direct_runtime/mod.rs` 的守卫用例、`direct_project_context.rs`、`direct_tool_bridge.rs`、`direct_tools_mcp.rs`、 + `user_input.rs`、`codex_app_server/mod.rs` 的集成用例),取消路径改为直接走无条件的 `complete_direct_thread_turn("aborted")`。 +- 前置判据:补一条测试证明"回合任务被 park / 泄漏时,取消仍能清空占用并让队列继续放行"——那次 60 秒兜底来自真实事故 + (`d833ca9d3`),删它必须有等价保证。 + +## 验收与证据 + +- Rust:第 1、2、5 步各自的单测;`cargo test` 定向 + `cargo check --tests` 无新增告警。 +- Node:`npx vitest run tests/appSurface.test.ts`、`npm --workspace apps/ai-game-creator-shell run typecheck`。 +- 端到端(`chat-composer.suite.ts`):回合运行中入队两条 → 取消一条 → 终态后只放行剩下那条; + 入队只发生一次 IPC、没有第二次发送命令;满队提示;入队失败时草稿不丢。 +- 手工:两个窗口看同一项目(A 排队 B 可见可取消);离开工作台再回来队列仍在并继续放行; + `kill -9` 后重进队列消失(与 ADR 的已知边界一致)。 +- 全仓:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 不涉及 SpacetimeDB schema,不需要 `npm run check:spacetime-schema`。 From faf85837bdca7eb702ef9688067c5781ac0a3f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 20:58:08 +0800 Subject: [PATCH 02/60] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=EF=BC=9A=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F?= =?UTF-8?q?=E5=88=97=E7=9A=84=E5=85=A5=E9=98=9F/=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=8E=E9=98=9F=E5=88=97=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `DirectThreadEvent::QueueEnqueued`(`queue.enqueued`,带 canonical 用户条目与可选 `creationType`,不带 prompt)与 `QueueRemoved`(`queue.removed`,`reason` 为 typed 枚举 `cancelled | dispatched`) - 新增 typed 枚举 `DirectQueueRemovalReason` 与 `DirectQueueRemovalOutcome`,并重跑 ts-rs 绑定 - 新增 `agent/direct_thread_queue.rs`:`PendingDirectTurn`(入队时冻结 canonical 形状与 prompt)、上限 `MAX_PENDING_DIRECT_TURNS = 5`、`EnqueueOutcome` / `EnqueueRejection` - Thread Manager 的 `observe_event` 认队列事件:在队期间的 `queue.enqueued` 不可回收,`queue.removed` 把它转成可回收,新订阅者的 bootstrap 因此天然看得见当前队列 - `DirectCodexUserItem` 及其子类型补 `PartialEq`,`DirectThreadEvent` 不再需要 `Eq` --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_user_item/model.rs | 12 +- .../src/agent/direct_thread_manager.rs | 17 ++ .../src/agent/direct_thread_queue.rs | 200 ++++++++++++++++++ .../src-tauri/src/agent/direct_thread_wire.rs | 166 ++++++++++++++- .../generated/DirectQueueRemovalOutcome.ts | 9 + .../generated/DirectQueueRemovalReason.ts | 9 + .../chat/generated/DirectThreadEvent.ts | 26 ++- 8 files changed, 429 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 347ec3139..781aad21d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -29,6 +29,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_queue; mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; @@ -65,6 +66,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_queue::*; pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b65f25ba4..2b537d5f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { @@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem { Message(DirectCodexUserMessageItem), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { @@ -19,14 +19,14 @@ pub(crate) struct DirectCodexUserMessageItem { pub(crate) id: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { @@ -43,7 +43,7 @@ pub(crate) enum DirectCodexUserContentPart { AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserAttachmentReferencePart { @@ -55,7 +55,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) status: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2b624df55..2e66f086c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -428,6 +428,14 @@ impl DirectThreadManager { true } DirectThreadEvent::ItemDelta { .. } => true, + // 待发消息在队期间不可回收:队列的成员与顺序**就是**这条事件本身 + // (见 `direct_thread_queue`)。bootstrap 因此天然看得见当前队列。 + DirectThreadEvent::QueueEnqueued { .. } => false, + // 离开队列(取消 / 放行)才把同一条待发消息的入队事件一起转成可回收。 + DirectThreadEvent::QueueRemoved { client_turn_id, .. } => { + Self::mark_queue_events_cleanable(thread, client_turn_id); + true + } } } @@ -471,6 +479,15 @@ impl DirectThreadManager { } } + /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 + fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { + for stored in &mut thread.events { + if stored.event.queue_client_turn_id() == Some(client_turn_id) { + stored.cleanable = true; + } + } + } + fn trim_prefix(thread: &mut ThreadState) { let min_cursor = thread .subscribers diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs new file mode 100644 index 000000000..2785c87cb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -0,0 +1,200 @@ +//! DirectProject 待发消息队列的条目与规则。 +//! +//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的 +//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此 +//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。 +//! +//! 这个模块只放三件事:一条待发消息带走什么([`PendingDirectTurn`])、容量规则 +//! ([`MAX_PENDING_DIRECT_TURNS`])、以及它在线上长什么样([`PendingDirectTurn::enqueued_event`])。 +//! 它不碰锁、不碰 Tauri、不写盘:入队检查在命令侧,放行顺序在 Thread Manager。 + +use serde_json::Value; + +use crate::agent::{ + direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, DirectThreadEvent, +}; + +/// 一个项目最多能同时排队的待发消息条数。 +/// +/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回 +/// typed 入队失败。 +pub(crate) const MAX_PENDING_DIRECT_TURNS: usize = 5; + +/// 一条已经通过入队检查、正在等放行的用户消息。 +/// +/// 只在内存里,进程重启即消失(与 ADR 记的边界一致)。它同时是**放行时要用的全部输入**:放行 +/// 没有失败出口,所以检查产物在入队时就地冻结,放行只搬运、不重算。 +#[derive(Clone, Debug)] +pub(crate) struct PendingDirectTurn { + /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + pub(crate) client_turn_id: String, + /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 + pub(crate) user_item: DirectCodexUserItem, + /// canonical 用户条目的 JSON 形状:入队时算好,放行时直接落盘。 + pub(crate) canonical_user_item: Value, + /// 入队检查产出的 prompt:放行不重算。 + pub(crate) prompt: String, + pub(crate) creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + pub(crate) at: u64, +} + +impl PendingDirectTurn { + /// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。 + /// + /// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里 + /// (canonical 形状与 prompt 都是)。 + pub(crate) fn prepare( + client_turn_id: String, + user_item: DirectCodexUserItem, + prompt: String, + creation_type: Option, + at: u64, + ) -> Result { + let canonical_user_item = serde_json::to_value(&user_item)?; + Ok(Self { + client_turn_id, + user_item, + canonical_user_item, + prompt, + creation_type, + at, + }) + } + + /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 只留在宿主的 + /// 队列条目里,它不是要下发的展示形状)。 + pub(crate) fn enqueued_event(&self) -> DirectThreadEvent { + DirectThreadEvent::queue_enqueued( + self.client_turn_id.clone(), + self.user_item.clone(), + self.creation_type.clone(), + self.at, + ) + } + + /// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。 + pub(crate) fn user_item_id(&self) -> Option { + direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id) + } +} + +/// 入队的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueOutcome { + /// 这次真的排进队尾了。 + Enqueued, + /// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。 + AlreadyKnown, +} + +/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueRejection { + /// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。 + QueueFull, +} + +/// 队列还有没有位置。`pending_count` 只数在队条目。 +pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> { + if pending_count >= MAX_PENDING_DIRECT_TURNS { + return Err(EnqueueRejection::QueueFull); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + "id": id, + })) + .expect("canonical user item") + } + + fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + user_item("生成一个游戏", "direct-codex:turn-1:user"), + "生成一个游戏".to_string(), + creation_type.map(str::to_string), + 1_700_000_000_123, + ) + .expect("prepare pending turn") + } + + /// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物, + /// 不许顺着事件下发。 + #[test] + fn enqueued_event_carries_the_canonical_item_and_no_prompt() { + let event = pending("turn-1", Some("web-game")).enqueued_event(); + let value = serde_json::to_value(&event).expect("serialize queue.enqueued"); + assert_eq!( + value, + json!({ + "type": "queue.enqueued", + "clientTurnId": "turn-1", + "userItem": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "生成一个游戏"}], + "id": "direct-codex:turn-1:user", + }, + "creationType": "web-game", + "at": 1_700_000_000_123u64, + }) + ); + assert!(value.get("prompt").is_none(), "{value}"); + + // 没有创建类型时不写字段,也不补 `null`。 + let bare = serde_json::to_value(pending("turn-2", None).enqueued_event()) + .expect("serialize queue.enqueued without creation type"); + assert!(bare.get("creationType").is_none(), "{bare}"); + + // 事件读得出自己的待发消息身份。 + assert_eq!(event.queue_client_turn_id(), Some("turn-1")); + assert_eq!(event.queue_removal_reason(), None); + } + + /// canonical 形状在入队时就冻结:放行时落盘的就是这一份,不再重算。 + #[test] + fn prepare_freezes_the_canonical_item() { + let turn = pending("turn-1", None); + assert_eq!( + turn.canonical_user_item["id"], + json!("direct-codex:turn-1:user") + ); + assert_eq!( + turn.canonical_user_item, + serde_json::to_value(&turn.user_item).expect("serialize user item") + ); + } + + /// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。 + #[test] + fn user_item_id_derives_from_the_client_turn_id() { + assert_eq!( + pending("turn-1", None).user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + assert_eq!(pending(" ", None).user_item_id(), None); + } + + /// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。 + #[test] + fn capacity_closes_at_five_pending_turns() { + for count in 0..MAX_PENDING_DIRECT_TURNS { + assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置"); + } + assert_eq!( + queue_has_room(MAX_PENDING_DIRECT_TURNS), + Err(EnqueueRejection::QueueFull) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index 952dd3a24..a4649938e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectCodexUserItem; use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; @@ -212,6 +213,33 @@ impl DirectThreadRequestKind { } } +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectQueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经接单并成为回合。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectQueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 @@ -259,7 +287,7 @@ impl DirectTurnFailure { /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadEvent { @@ -321,6 +349,34 @@ pub(crate) enum DirectThreadEvent { #[serde(default)] request_id: Option, }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件不带 prompt:prompt 是入队检查的产物,只留在宿主的队列条目里。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: DirectCodexUserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: DirectQueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, } impl DirectThreadEvent { @@ -417,6 +473,51 @@ impl DirectThreadEvent { Self::Request { kind, request_id } } + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: DirectQueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 /// /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 @@ -427,6 +528,8 @@ impl DirectThreadEvent { | Self::TurnCompleted { at, .. } | Self::ItemStarted { at, .. } | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, Self::ItemDelta { .. } | Self::Request { .. } => None, } } @@ -456,7 +559,7 @@ impl DirectThreadEvent { } } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadSubscriptionBootstrap { @@ -468,7 +571,7 @@ pub(crate) struct DirectThreadSubscriptionBootstrap { pub(crate) events: Vec, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadConsumeResult { @@ -1466,4 +1569,61 @@ mod tests { .expect("failed turn without failure payload"); assert_eq!(sparse.failure(), None); } + + /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, + /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 + #[test] + fn queue_removed_carries_a_typed_reason() { + let dispatched = DirectThreadEvent::queue_removed( + "turn-1".to_string(), + DirectQueueRemovalReason::Dispatched, + 2_000, + ); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(DirectQueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts new file mode 100644 index 000000000..5a70720f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 取消一条待发消息的结果。 + */ +export type DirectQueueRemovalOutcome = + | 'removed' + | 'alreadyDispatched' + | 'notFound'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts new file mode 100644 index 000000000..90f8b8247 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条待发消息离开队列的原因。 + * + * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 + * (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 + */ +export type DirectQueueRemovalReason = 'cancelled' | 'dispatched'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index da0136295..630a41e95 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; +import type { DirectQueueRemovalReason } from './DirectQueueRemovalReason'; import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; @@ -84,8 +86,26 @@ export type DirectThreadEvent = kind: DirectThreadDeltaKind; delta: string; } + | { type: 'request'; kind: DirectThreadRequestKind; requestId: string | null } | { - type: 'request'; - kind: DirectThreadRequestKind; - requestId: string | null; + type: 'queue.enqueued'; + /** + * 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + */ + clientTurnId: string; + /** + * canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + */ + userItem: DirectCodexUserItem; + creationType?: string; + /** + * 入队那一刻的宿主毫秒钟。 + */ + at: number; + } + | { + type: 'queue.removed'; + clientTurnId: string; + reason: DirectQueueRemovalReason; + at?: number; }; From 053e0bd93220b118e997ce59edf9f062a81a7cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 21:11:15 +0800 Subject: [PATCH 03/60] =?UTF-8?q?Thread=20Manager=EF=BC=9A=E5=BE=85?= =?UTF-8?q?=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97=E7=9A=84=E5=85=A5?= =?UTF-8?q?=E9=98=9F=20/=20=E5=8F=96=E6=B6=88=20/=20=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E8=AE=A4=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `StoredEvent` 增加非序列化的 `pending` 产物字段:它的有无就是队列成员身份,队列的先后就是事件先后,不另建队列表 - 新增 `enqueue_pending_turn`:按 `clientTurnId` 幂等(判重范围「在队 ∪ 正在跑的那一轮」),容量用 `queue_has_room` 挡在队条目 - 新增 `remove_pending_turn`:typed 结果 `Removed | AlreadyDispatched | NotFound`,在临界区里取走产物并追加 `queue.removed{cancelled}` - 新增 `claim_pending_turn`:同一临界区取队首 → 登记占用 → 追加 `turn.started` 与 `queue.removed{dispatched}`,已有未收口回合或队列为空时返回 `None` - `mark_queue_events_cleanable` 跳过仍挂着产物的条目,保证在队条目永远不会被回收 - 新增薄包装 `enqueue_direct_pending_turn` / `remove_direct_pending_turn` / `claim_direct_pending_turn`,并补 5 条单测覆盖 bootstrap 可见、幂等、容量、放行原子性与三种取消结果 --- .../src/agent/direct_thread_manager.rs | 451 +++++++++++++++++- 1 file changed, 449 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2e66f086c..a83e68baa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -9,7 +9,9 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ + direct_tool_call_now_ms, queue_has_room, DirectQueueRemovalOutcome, DirectQueueRemovalReason, DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, + EnqueueOutcome, EnqueueRejection, PendingDirectTurn, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -28,6 +30,12 @@ struct StoredEvent { seq: u64, bytes: usize, cleanable: bool, + /// 这条事件在队期间挂着的宿主侧产物。 + /// + /// 只有 `queue.enqueued` 会带它,而且**它本身就是队列成员身份**:`Some` = 这条待发消息还在队, + /// 被取走 = 已经离开队列。队列的先后就是事件的先后,所以没有第二张队列表要对齐,也不会出现 + /// "事件说在队、队列表说不在"的中间态(见 `direct_thread_queue`)。 + pending: Option, } #[derive(Clone, Debug)] @@ -75,6 +83,16 @@ pub(crate) struct DirectActiveTurnSnapshot { pub(crate) sequence: u64, } +/// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 +/// +/// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 +/// 重跑任何检查,也就不需要再回到调用方取参数。 +#[derive(Clone, Debug)] +pub(crate) struct DirectDispatchedTurn { + pub(crate) token: String, + pub(crate) pending: PendingDirectTurn, +} + #[derive(Clone, Debug)] struct ThreadState { next_seq: u64, @@ -142,6 +160,16 @@ impl DirectThreadManager { &mut self, thread_id: &str, event: DirectThreadEvent, + ) -> DirectThreadEvent { + self.append_inner(thread_id, event, None) + } + + /// 追加事件。`pending` 用来把宿主侧产物挂到这条事件上(只有入队会传)。 + fn append_inner( + &mut self, + thread_id: &str, + event: DirectThreadEvent, + pending: Option, ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); @@ -156,6 +184,7 @@ impl DirectThreadManager { seq, bytes, cleanable, + pending, }); if let Some(item_id) = event.item_id() { Self::mark_item_events_cleanable(thread, item_id, seq); @@ -331,6 +360,153 @@ impl DirectThreadManager { .is_some_and(|thread| thread.active_turn.is_some()) } + /// 在队的待发消息,队首在前。 + fn pending_turns(thread: &ThreadState) -> Vec<&PendingDirectTurn> { + thread + .events + .iter() + .skip(thread.head) + .filter_map(|stored| stored.pending.as_ref()) + .collect() + } + + /// 入队:把一条已经通过入队检查的待发消息排到队尾。 + /// + /// 幂等按 `clientTurnId`,判重范围是「**在队 ∪ 正在跑的那一轮**」:同一身份重复入队返回 + /// [`EnqueueOutcome::AlreadyKnown`],不排第二条、不发第二条事件。容量只数在队条目。 + fn enqueue_pending_turn( + &mut self, + thread_id: &str, + turn: PendingDirectTurn, + ) -> Result { + { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let already_known = Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == turn.client_turn_id) + || thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == turn.client_turn_id); + if already_known { + return Ok(EnqueueOutcome::AlreadyKnown); + } + queue_has_room(Self::pending_turns(thread).len())?; + } + let event = turn.enqueued_event(); + self.append_inner(thread_id, event, Some(turn)); + Ok(EnqueueOutcome::Enqueued) + } + + /// 取消一条待发消息。typed 结果告诉调用方它到底是被取消、已经放行,还是本来就不在队里。 + /// + /// 判「已经被放行」用的是正在跑的那一轮的回合身份:认领之后就再也按待发消息取消不了了。 + fn remove_pending_turn( + &mut self, + thread_id: &str, + client_turn_id: &str, + at_ms: u64, + ) -> DirectQueueRemovalOutcome { + let removed = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return DirectQueueRemovalOutcome::NotFound; + }; + if thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == client_turn_id) + { + return DirectQueueRemovalOutcome::AlreadyDispatched; + } + let head = thread.head; + let mut removed = false; + for stored in thread.events.iter_mut().skip(head) { + if stored + .pending + .as_ref() + .is_some_and(|pending| pending.client_turn_id == client_turn_id) + { + // 取走产物即离开队列:这一幕在临界区里发生,外面看不到"事件还在、条目已经不在"。 + stored.pending = None; + removed = true; + } + } + removed + }; + if !removed { + return DirectQueueRemovalOutcome::NotFound; + } + self.append( + thread_id, + DirectThreadEvent::queue_removed( + client_turn_id.to_string(), + DirectQueueRemovalReason::Cancelled, + at_ms, + ), + ); + DirectQueueRemovalOutcome::Removed + } + + /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 + /// + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的。这两件事 + /// 都由这里判,调用方(kick)不需要先探一遍再决定——探两次就是两个中间态。 + /// + /// 认领认的是队首,不是调用方点名的某一条:待发消息的顺序就是事件顺序,没有第三种来源。 + fn claim_pending_turn( + &mut self, + thread_id: &str, + started_at_ms: u64, + ) -> Option { + let pending = { + let thread = self.threads.get_mut(thread_id)?; + if thread.active_turn.is_some() { + return None; + } + let head = thread.head; + let stored = thread + .events + .iter_mut() + .skip(head) + .find(|stored| stored.pending.is_some())?; + stored.pending.take().expect("filtered on a pending turn") + }; + let token = Uuid::new_v4().to_string(); + let turn_id = pending.client_turn_id.clone(); + let project_name = std::path::Path::new(thread_id) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string); + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = Some(ActiveDirectTurn { + token: token.clone(), + turn_id: turn_id.clone(), + project_name, + started_at: started_at_ms, + // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: started_at_ms, + sequence: 0, + }); + } + let user_item_id = pending.user_item_id(); + self.append( + thread_id, + DirectThreadEvent::turn_started(started_at_ms) + .with_user_item_id(user_item_id.as_deref()), + ); + self.append( + thread_id, + DirectThreadEvent::queue_removed( + turn_id, + DirectQueueRemovalReason::Dispatched, + started_at_ms, + ), + ); + Some(DirectDispatchedTurn { token, pending }) + } + fn subscriber_ids(&self, thread_id: &str) -> Vec { self.threads .get(thread_id) @@ -395,6 +571,20 @@ impl DirectThreadManager { }) } + /// 当前在队的待发消息身份,队首在前。 + #[cfg(test)] + fn pending_turn_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| { + Self::pending_turns(thread) + .iter() + .map(|pending| pending.client_turn_id.clone()) + .collect() + }) + .unwrap_or_default() + } + /// 观察一条事件,返回它本身是否可回收。 fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { match event { @@ -482,7 +672,10 @@ impl DirectThreadManager { /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { for stored in &mut thread.events { - if stored.event.queue_client_turn_id() == Some(client_turn_id) { + // 仍挂着产物的条目永远不算可回收:在队条目被回收就等于队列悄悄丢了一条消息。 + if stored.pending.is_none() + && stored.event.queue_client_turn_id() == Some(client_turn_id) + { stored.cleanable = true; } } @@ -601,6 +794,56 @@ pub(crate) fn accept_direct_thread_turn( Ok(()) } +/// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `direct_thread_queue`)。 +pub(crate) fn enqueue_direct_pending_turn( + thread_id: &str, + turn: PendingDirectTurn, +) -> Result { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .enqueue_pending_turn(thread_id, turn) + }; + if matches!(outcome, Ok(EnqueueOutcome::Enqueued)) { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + +/// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 +pub(crate) fn remove_direct_pending_turn( + thread_id: &str, + client_turn_id: &str, +) -> DirectQueueRemovalOutcome { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + }; + if outcome == DirectQueueRemovalOutcome::Removed { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + +/// 认领队首并登记占用(放行的原子步骤,见 [`DirectThreadManager::claim_pending_turn`])。 +/// +/// `None` = 现在不该放行(已有未收口的回合,或队列为空)。 +pub(crate) fn claim_direct_pending_turn(thread_id: &str) -> Option { + let claimed = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + }; + if claimed.is_some() { + notify_direct_thread_subscribers(thread_id); + } + claimed +} + /// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回是否真的写进去了。 pub(crate) fn update_direct_thread_active_turn( thread_id: &str, @@ -700,7 +943,9 @@ pub(crate) fn consume_direct_thread( #[cfg(test)] mod tests { use super::*; - use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; + use crate::agent::{ + DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind, MAX_PENDING_DIRECT_TURNS, + }; fn message(item_id: &str) -> DirectThreadItem { DirectThreadItem::Message { @@ -1110,4 +1355,206 @@ mod tests { assert_eq!(conflict.as_deref(), Some("turn-1")); } + + fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + format!("消息 {client_turn_id}"), + None, + FIXED_AT_MS, + ) + .expect("prepare pending turn") + } + + fn enqueued_ids(events: &[DirectThreadEvent]) -> Vec<&str> { + events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::QueueEnqueued { client_turn_id, .. } => { + Some(client_turn_id.as_str()) + } + _ => None, + }) + .collect() + } + + /// 队列就是事件列表:新订阅者的 bootstrap 看得见当前队列,取消之后那条就从队列里消失。 + #[test] + fn new_subscribers_see_the_current_queue_until_it_is_removed() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-1", "turn-2"]); + + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::Removed + ); + // 离开队列的事件带 typed 原因:这条是用户取消。 + let events = manager + .consume(&watcher.subscription_id) + .expect("consume removal") + .events; + assert!( + matches!( + events.as_slice(), + [DirectThreadEvent::QueueRemoved { client_turn_id, reason, .. }] + if client_turn_id == "turn-1" && *reason == DirectQueueRemovalReason::Cancelled + ), + "{events:?}" + ); + // 队尾那条仍在,队首那条不再对新订阅者可见。 + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 + #[test] + fn enqueue_is_idempotent_per_client_turn_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue again"), + EnqueueOutcome::AlreadyKnown + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-1"]); + + // 正在跑的那一轮同样算已知:它的身份已经在占用里,不该再排一条等放行。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue while running"), + EnqueueOutcome::AlreadyKnown + ); + assert!(manager.pending_turn_ids("thread-1").is_empty()); + } + + /// 容量只数在队条目:正在跑的那一轮不占等待位。 + #[test] + fn capacity_counts_only_pending_turns() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + for index in 0..MAX_PENDING_DIRECT_TURNS { + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + } + assert_eq!( + manager.enqueue_pending_turn("thread-1", pending_turn("turn-overflow")), + Err(EnqueueRejection::QueueFull) + ); + + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-overflow")) + .expect("enqueue after dispatch"), + EnqueueOutcome::Enqueued + ); + } + + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 + #[test] + fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + let claimed = manager + .claim_pending_turn("thread-1", 2_000) + .expect("claim head"); + assert_eq!(claimed.pending.client_turn_id, "turn-1"); + assert_eq!(claimed.pending.prompt, "消息 turn-1"); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + + let events = manager + .consume(&watcher.subscription_id) + .expect("consume dispatch") + .events; + assert!( + matches!( + events.as_slice(), + [ + DirectThreadEvent::TurnStarted { at, .. }, + DirectThreadEvent::QueueRemoved { client_turn_id, reason, at: removed_at }, + ] if *at == Some(2_000) + && client_turn_id == "turn-1" + && *reason == DirectQueueRemovalReason::Dispatched + && *removed_at == Some(2_000) + ), + "{events:?}" + ); + + // 已经有未收口的回合:认领不再放行第二条。 + assert!(manager.claim_pending_turn("thread-1", 2_100).is_none()); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + } + + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 + #[test] + fn removal_tells_cancelled_dispatched_and_unknown_apart() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::Removed + ); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-2", FIXED_AT_MS), + DirectQueueRemovalOutcome::AlreadyDispatched + ); + } } From 132f96581addb98880164d5ae1a8e8e423dda355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 21:36:22 +0800 Subject: [PATCH 04/60] =?UTF-8?q?DirectProject=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=85=A5=E9=98=9F=E5=8C=96=EF=BC=9A=E6=94=BE=E8=A1=8C=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E3=80=81=E9=98=9F=E5=88=97=E7=A7=BB=E9=99=A4=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B8=8E=E8=AF=8D=E8=A1=A8=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令改名 enqueue_direct_codex_turn:跑完入队检查后只入队,返回结构化的入队失败载荷 - direct_turn_accept.rs 改为 direct_turn_dispatch.rs:放行占用对象改为接管 Thread Manager 认领好的那一轮 - 新增 kick_direct_queue_dispatch:入队、占用释放与中止路径共用同一个幂等放行点 - 放行之后取调用身份、落盘用户条目、下发条目、跑整轮,失败一律收口成回合失败 - 新增 remove_direct_project_pending_turn 命令,取消一条还没放行的待发消息 - 队列条目补 analytics_attempt_id:埋点身份跟着入队走,放行不重算 - DirectTurnRejection 改名 DirectTurnEnqueueFailure,新增 QueueFull 变体 - 退役 accept_turn / accept_direct_thread_turn,测试改用 accept_for_test 建占用 - 前端与生成绑定同步改名,DirectTurnRejection.ts 换成 DirectTurnEnqueueFailure.ts --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 37 +- .../src/agent/direct_project_context.rs | 26 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 30 +- .../src/agent/direct_runtime/user_input.rs | 328 +++++++--------- .../src/agent/direct_thread_manager.rs | 100 +---- .../src/agent/direct_thread_queue.rs | 5 + .../src-tauri/src/agent/direct_thread_wire.rs | 6 +- .../src-tauri/src/agent/direct_turn_accept.rs | 282 -------------- .../src/agent/direct_turn_dispatch.rs | 357 ++++++++++++++++++ .../src-tauri/src/agent/direct_turn_error.rs | 47 ++- .../src/agent/direct_turn_failure.rs | 2 +- .../src-tauri/src/commands.rs | 23 ++ .../src-tauri/src/main.rs | 3 +- .../src/features/agent-runtime/model.ts | 6 +- .../chat/DirectProjectChatView.tsx | 2 +- .../useDirectProjectChatController.ts | 88 ++--- .../controller/useDirectProjectTurnStatus.ts | 4 +- .../conversation/directCodexConversation.ts | 46 ++- .../chat/conversation/directThreadChat.ts | 4 +- .../conversation/directTurnPresentation.ts | 16 +- .../chat/generated/DirectCodexFailureStage.ts | 2 +- .../chat/generated/DirectModelCallKind.ts | 2 +- .../generated/DirectQueueRemovalReason.ts | 2 +- .../chat/generated/DirectThreadEvent.ts | 2 +- .../chat/generated/DirectTurnDeadline.ts | 2 +- ...jection.ts => DirectTurnEnqueueFailure.ts} | 4 +- .../chat/generated/DirectTurnError.ts | 1 + .../tests/agentRuntimeModel.test.ts | 6 +- .../tests/appSurface/chat-composer.suite.ts | 58 +-- .../tests/appSurface/home.suite.ts | 104 +++-- .../appSurface/project-conversation.suite.ts | 36 +- .../tests/directThreadChat.test.ts | 4 +- .../tests/directTurnPresentation.test.ts | 2 +- 34 files changed, 800 insertions(+), 841 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectTurnRejection.ts => DirectTurnEnqueueFailure.ts} (85%) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 781aad21d..0bde49504 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,7 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; -mod direct_turn_accept; +mod direct_turn_dispatch; mod direct_turn_error; mod direct_turn_failure; mod direct_turn_stream; @@ -71,7 +71,7 @@ pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_accept::*; +pub(crate) use direct_turn_dispatch::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_stream::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index f2058531c..4538e1c57 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -831,10 +831,10 @@ fn direct_thread_visible_item( direct_thread_event_item(root, item) } -/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。 +/// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 /// /// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的 -/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上 +/// 开口用户条目。发点在 `turn/start` 之后时,"放行到 `turn/start` 之间"的失败(连不上 /// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮** /// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成 /// 一个 0.0 秒的假回合。 @@ -846,7 +846,7 @@ pub(crate) fn emit_direct_thread_user_item( item: &serde_json::Value, ) -> Option { let entry_item = direct_thread_event_item(root, item)?; - // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); append_direct_thread_event( @@ -3300,11 +3300,11 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写; + // 用户条目由放行半在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: - // 不落盘,因为落盘的时机属于接单动作,不属于这里。 + // 不落盘,因为落盘的时机属于放行动作,不属于这里。 let mut direct_turn_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); @@ -3569,8 +3569,8 @@ impl CodexAppServerConnection { let direct_turn_user_item_id = direct_turn_user_item .as_ref() .and_then(direct_thread_item_identity); - // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 - // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、 + // 逻辑回合的**边界**不在这里:开始事件由认领动作发出、兜底由放行占用对象持有 + // (`direct_turn_dispatch.rs`)。本轮的用户条目也不在这里下发——发点在放行之后、 // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。 // // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 @@ -4528,11 +4528,14 @@ pub(crate) fn cancel_direct_codex_turn_at( // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 + // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的放行。 complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); + // 这一轮不会再有人替它收尾(守卫刚被兜底释放),占用也在这里解除了:踢一脚让队列继续。 + // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 + crate::agent::kick_direct_queue_dispatch(root); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -8048,12 +8051,8 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = + crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8213,14 +8212,10 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行,再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = + crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 5e00818dd..971d41931 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -437,14 +437,12 @@ mod tests { async fn a_replaced_active_turn_marks_the_batch_stale() { let (_temp, root) = project(); std::fs::write(root.join("code.js"), "unchanged").unwrap(); - // 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。 + // 身份来自逻辑回合(Thread Manager):放行才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTurnReservation::accept( + DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "turn-before", - None, - ) - .unwrap(), + ), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -455,14 +453,10 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(r), - "turn-after", - None, - ) - .unwrap(), - ); + *guard = Some(DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(r), + "turn-after", + )); result }, ) @@ -598,12 +592,10 @@ mod tests { let (_temp, root) = project(); // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); - let _turn = DirectTurnReservation::accept( + let _turn = DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "prefetch-turn", - None, - ) - .unwrap(); + ); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index d43d954ca..fdf0ded68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; -pub(crate) use user_input::chat_with_game_creator_direct_codex; +pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; @@ -2142,13 +2142,13 @@ pub(crate) fn record_direct_codex_failure( recovery_hint, diagnostics_suffix, ); - // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); public_text } -/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用), +/// 命令边界的错误文本:可留痕的调用级入队失败在这里补一份运行错误诊断(文案里不带诊断引用), /// 其余只输出 [`DirectTurnError`] 的 `Display`。 /// /// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 @@ -2159,23 +2159,23 @@ pub(crate) fn direct_turn_error_boundary_text( client_turn_id: Option<&str>, failure: DirectTurnError, ) -> String { - direct_turn_rejection(root, client_turn_id, failure).message + direct_turn_enqueue_failure(root, client_turn_id, failure).message } -/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// /// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 -pub(crate) fn direct_turn_rejection( +pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, failure: DirectTurnError, -) -> DirectTurnRejection { +) -> DirectTurnEnqueueFailure { if !failure.is_reportable() { - return DirectTurnRejection::new(failure); + return DirectTurnEnqueueFailure::new(failure); } let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnRejection { + DirectTurnEnqueueFailure { error: failure, message, } @@ -4409,9 +4409,9 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( .collect()) } -/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队半调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, @@ -4450,8 +4450,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt: &str, creation_type: Option<&str>, ) -> Result { - // CLI 入口没有"接单"这一步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在同一步骤之后才接单,两边共用这一份判据。 + // CLI 入口没有"入队 / 放行"这两步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; + // GUI 命令在入队半里过同一份判据。 check_direct_turn_preconditions(root, prompt, creation_type)?; run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, @@ -4465,7 +4465,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( .await } -async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( +pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, creation_type: Option<&str>, @@ -4495,7 +4495,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( { Ok(reply) => Ok(reply), // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 - // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 在入队前就查过,能到这条通道的只有放行之后的事(连接、配置、历史注入、`turn/start` // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 39665ac14..de0bdc053 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -1,7 +1,7 @@ //! DirectProject 用户输入命令适配器。 //! -//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item -//! 深模块,回合编排仍由父模块负责。 +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item 深模块,队列交给 +//! Thread Manager;放行在 `direct_turn_dispatch`,不在这里。 use super::*; @@ -28,31 +28,33 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 +/// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 -/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 -/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 +/// 入队半就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt +/// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 +/// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// -/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 -/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 +/// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `direct_turn_dispatch`), +/// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 +/// 错误诊断,返回串不带诊断引用。 /// /// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 -/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// 打到终端上,没有事件订阅可用;两个入口共用 [`direct_turn_error_boundary_text`] / +/// [`direct_turn_enqueue_failure`],不要再各写一套判据。 /// -/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 +/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( +pub(crate) async fn enqueue_direct_codex_turn( project_path: String, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result<(), DirectTurnRejection> { +) -> Result<(), DirectTurnEnqueueFailure> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( root, user_item, creation_type, @@ -60,17 +62,16 @@ pub(crate) async fn chat_with_game_creator_direct_codex( analytics_attempt_id, ) .await - .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) + .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: -/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 -/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// 入队半:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 /// -/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 -/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 -/// 事件会排在用户消息之后。 -async fn chat_with_game_creator_direct_codex_typed( +/// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; +/// 没通过检查的东西不进队列。入队本身只做判重与容量,不再有第二种副作用——落盘、回合事件与整轮 +/// 都在放行那一侧。 +async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, creation_type: Option, @@ -78,10 +79,6 @@ async fn chat_with_game_creator_direct_codex_typed( analytics_attempt_id: Option, ) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是** - // 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生 - // 共死——随任务一起搬进后台。 - let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { DirectTurnError::HostStateUnavailable { detail: redact_agent_runtime_error( @@ -96,10 +93,6 @@ async fn chat_with_game_creator_direct_codex_typed( let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; - let canonical_user_item = - serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { - detail: error.to_string(), - })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -107,96 +100,31 @@ async fn chat_with_game_creator_direct_codex_typed( let detail = redact_agent_runtime_error(root, &error, 1800); DirectTurnError::EnvironmentNotReady { detail } })?; - // 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填** - // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 - let thread_id = direct_thread_id_for_project(root); - let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id); - let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; - // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 - if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { - // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 - // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 - // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 - // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 - // 用户消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); - return Ok(()); - } - // 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上 - // app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到 - // `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。 - crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item); - let capture = crate::analytics::gui::capture_writer_context(); - let root = root.to_path_buf(); - tauri::async_runtime::spawn(async move { - run_accepted_direct_turn( - root, - turn_id, - user_prompt, - creation_type, - canonical_user_item, - capture, - analytics_attempt_id, - active_invocation, - reservation, - ) - .await; - }); - Ok(()) -} - -/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。 -/// -/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的 -/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。 -/// -/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里: -/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation` -/// 是逻辑回合的占用。 -#[allow(clippy::too_many_arguments)] -async fn run_accepted_direct_turn( - root: std::path::PathBuf, - turn_id: String, - user_prompt: String, - creation_type: Option, - canonical_user_item: serde_json::Value, - capture: Option<( - crate::analytics::contract::Context, - crate::analytics::store::AnalyticsWriter, - )>, - analytics_attempt_id: Option, - _active_invocation: DirectTaonierActiveInvocationGuard, - reservation: DirectTurnReservation, -) { - let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); - let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( - &root, - &user_prompt, - creation_type.as_deref(), - Some(&emitter), - Some(canonical_user_item), - capture, - analytics_attempt_id.as_deref(), + // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。prompt 与 canonical 形状在这里 + // 冻结——放行不重算,所以放行没有失败出口。 + let pending = PendingDirectTurn::prepare( + turn_id, + user_item, + user_prompt, + creation_type, + analytics_attempt_id, + direct_tool_call_now_ms(), ) - .await; - match outcome { - Ok(reply) => { - // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); - } - Err(error) => { - // 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 - // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + .map_err(|error| DirectTurnError::InputRejected { + detail: error.to_string(), + })?; + let thread_id = direct_thread_id_for_project(root); + match enqueue_direct_pending_turn(&thread_id, pending) { + Ok(_) => {} + Err(EnqueueRejection::QueueFull) => { + return Err(DirectTurnError::QueueFull { + limit: MAX_PENDING_DIRECT_TURNS, + }) } } + // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 + kick_direct_queue_dispatch(root); + Ok(()) } #[cfg(test)] @@ -204,46 +132,64 @@ mod tests { use super::*; use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent}; - /// 接单之后的早退也必须有终态。 + /// 放行之后整轮是异步的:测试只能等事件。返回从订阅之后看到的全部事件。 + async fn wait_for_completion(subscription_id: &str) -> Vec { + let mut events = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + events.extend( + consume_direct_thread(subscription_id) + .expect("consume logical turn") + .events, + ); + if events + .iter() + .any(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "回合没有收口:{events:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + fn user_item(text: &str) -> DirectCodexUserItem { + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": text }], + })) + .expect("canonical user item") + } + + /// 放行之后发现的失败也必须收口。 /// - /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 - /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 - /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + /// 命令此刻早就返回了 `Ok`(它只回报入队成立),前端唯一的收口依据就是事件流:占用对象必须补出 + /// `turn.completed`。这里用一个"目录存在但不是项目"的根制造一条放行之后才发现的失败。 #[tokio::test] - async fn a_failure_after_accept_still_closes_the_logical_turn() { + async fn a_failure_after_dispatch_still_closes_the_logical_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("not-a-project"); std::fs::create_dir_all(&root).expect("create project dir"); let thread_id = direct_thread_id_for_project(&root); let subscription = subscribe_direct_thread(&thread_id); let _ = consume_direct_thread(&subscription.subscription_id); - let reservation = - DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user")) - .expect("accept logical turn"); - let invocation = - DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); - run_accepted_direct_turn( - root.clone(), - "turn-1".to_string(), - "你好".to_string(), + enqueue_direct_codex_turn_typed( + &root, + user_item("你好"), None, - serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "你好" }], - }), + Some("turn-1".to_string()), None, - None, - invocation, - reservation, ) - .await; + .await + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminal = events .iter() .filter_map(|event| match event { @@ -262,20 +208,22 @@ mod tests { let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( !failure.message.trim().is_empty(), - "接单之后的失败必须带上原因" + "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); + // 占用已释放:队列继续走得动。 + assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); } /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。 /// - /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败 - /// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置 - /// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户 - /// 气泡再自成一个 0.0 秒的假回合。 + /// 现场(用户可见的坏体验):放行、用户条目落盘之后,整轮在 `turn/start` 之前就失败(连不上 + /// app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置落进**上一轮** + /// 的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户气泡再自成一个 + /// 0.0 秒的假回合。 /// - /// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目" - /// 一定在最前面,之后才可能有失败终态。 + /// 判据取事件流的前两条:放行任务的顺序是"开始 → 下发用户条目 → 跑整轮",所以终态一定在它们 + /// 之后。 #[tokio::test] async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() { let temp = tempfile::tempdir().expect("temp dir"); @@ -285,48 +233,50 @@ mod tests { let thread_id = direct_thread_id_for_project(&root); let subscription = subscribe_direct_thread(&thread_id); let _ = consume_direct_thread(&subscription.subscription_id); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "hello" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("hello"), None, Some("turn-1".to_string()), None, ) .await - .expect("接单成立:命令只回报接单"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; + // 队列事件先于放行:`queue.enqueued` → 放行(开始 + 离开队列)→ 开口用户条目 → 终态。 + let started = events + .iter() + .position(|event| matches!(event, DirectThreadEvent::TurnStarted { .. })) + .expect("这一轮必须被放行"); assert!( matches!( - events.first(), + events.get(started), Some(DirectThreadEvent::TurnStarted { user_item_id, .. }) if user_item_id.as_deref() == Some("direct-codex:turn-1:user") ), - "第一条必须是带身份的回合开始:{events:?}" + "放行的第一条事件必须是带身份的回合开始:{events:?}" ); + // 开口用户条目必须是这一轮里的**第一条条目事件**:它之前只许有回合边界与队列事件。 + let opening = events + .iter() + .position(|event| event.item_id().is_some()) + .expect("本轮的开口用户条目必须下发"); assert!( matches!( - events.get(1), + events.get(opening), Some(DirectThreadEvent::ItemCompleted { item, .. }) if item.item_id() == "direct-codex:turn-1:user" ), - "第二条必须是本轮的开口用户条目:{events:?}" + "第一条条目事件必须是本轮的开口用户条目:{events:?}" ); + assert!(opening > started, "用户条目只能在放行之后:{events:?}"); let terminal = events .iter() .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })); assert!( - terminal.is_none_or(|index| index > 1), + terminal.is_none_or(|index| index > opening), "终态只能在用户条目之后:{events:?}" ); // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。 @@ -338,14 +288,15 @@ mod tests { ); } - /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// 放行之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 /// /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 - /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 - /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 - /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + /// 说明、横幅又一份),而且 `Err` 的含义是**入队失败**——前端会把它读成"这一轮没开始"。历史 + /// 追加写有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把 + /// 这条路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能继续)。 #[tokio::test] - async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + async fn a_history_write_failure_after_dispatch_closes_the_turn_instead_of_failing_the_command() + { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-history-write-failure"); crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") @@ -359,27 +310,18 @@ mod tests { "9", ) .expect("write history contention injection"); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "生成一个游戏" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("生成一个游戏"), None, Some("turn-1".to_string()), None, ) .await - .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminals = events .iter() .filter_map(|event| match event { @@ -398,20 +340,14 @@ mod tests { "{}", failure.message ); - // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 - // 不许出现在这里(它只属于可留痕的拒单)。 + // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的入队失败)。 assert!( !failure.message.contains("direct-codex-failure"), "{}", failure.message ); - // 占用已释放:下一轮还能接单。 + // 占用已释放:下一轮还能继续。 assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); - assert!(DirectTurnReservation::accept( - &thread_id, - "turn-2", - Some("direct-codex:turn-2:user") - ) - .is_ok()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index a83e68baa..f3b844034 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -43,14 +43,14 @@ struct SubscriberState { cursor: u64, } -/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 +/// 一条正在跑的逻辑回合的占用:放行时登记,终态写出时解除。 /// /// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的 /// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`] /// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 /// /// 两个身份别混: -/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// - `token` 是这一次放行的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, /// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 /// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 #[derive(Clone, Debug)] @@ -227,46 +227,6 @@ impl DirectThreadManager { } } - /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 - /// - /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 - /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 - /// "撞的是同一轮还是另一轮"。 - fn accept_turn( - &mut self, - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, - ) -> Result { - { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - if let Some(active) = thread.active_turn.as_ref() { - return Err(active.turn_id.clone()); - } - thread.active_turn = Some(ActiveDirectTurn { - token: token.to_string(), - turn_id: turn_id.to_string(), - project_name: std::path::Path::new(thread_id) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), - started_at: started_at_ms, - // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at_ms, - sequence: 0, - }); - } - Ok(self.append( - thread_id, - DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), - )) - } - /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 /// /// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 @@ -449,10 +409,9 @@ impl DirectThreadManager { /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 /// - /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的。这两件事 - /// 都由这里判,调用方(kick)不需要先探一遍再决定——探两次就是两个中间态。 - /// - /// 认领认的是队首,不是调用方点名的某一条:待发消息的顺序就是事件顺序,没有第三种来源。 + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的——两件事 + /// 都由这里判,调用方(kick)不必先探一遍再决定。认领认的永远是队首:待发消息的顺序就是事件 + /// 顺序,没有第二种来源。 fn claim_pending_turn( &mut self, thread_id: &str, @@ -773,27 +732,6 @@ pub(crate) fn append_direct_thread_event( event } -/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 -/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: -/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 -/// 不要动当前回合。 -pub(crate) fn accept_direct_thread_turn( - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, -) -> Result<(), String> { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; - } - notify_direct_thread_subscribers(thread_id); - Ok(()) -} - /// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `direct_thread_queue`)。 pub(crate) fn enqueue_direct_pending_turn( thread_id: &str, @@ -830,7 +768,7 @@ pub(crate) fn remove_direct_pending_turn( /// 认领队首并登记占用(放行的原子步骤,见 [`DirectThreadManager::claim_pending_turn`])。 /// -/// `None` = 现在不该放行(已有未收口的回合,或队列为空)。 +/// `None` = 现在不该放行:已有未收口的回合,或队列为空。 pub(crate) fn claim_direct_pending_turn(thread_id: &str) -> Option { let claimed = { global_direct_thread_manager() @@ -1281,7 +1219,7 @@ mod tests { ); } - /// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。 + /// 首页快照就是逻辑回合的导出:放行即出现、进度按序号回填、收口即消失。 fn snapshot_of( manager: &DirectThreadManager, thread_id: &str, @@ -1299,8 +1237,11 @@ mod tests { assert!(snapshot_of(&manager, thread_id).is_none()); manager - .accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); + .enqueue_pending_turn(thread_id, pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn(thread_id, FIXED_AT_MS) + .expect("claim head"); let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); assert_eq!(accepted.turn_id, "turn-1"); assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); @@ -1340,22 +1281,6 @@ mod tests { assert!(snapshot_of(&manager, thread_id).is_none()); } - /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 - /// 里,界面拿它匹配不了自己发出的那一轮。 - #[test] - fn accept_conflict_returns_the_existing_turn_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager - .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) - .expect("accept"); - - let conflict = manager - .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) - .err(); - - assert_eq!(conflict.as_deref(), Some("turn-1")); - } - fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { PendingDirectTurn::prepare( client_turn_id.to_string(), @@ -1368,6 +1293,7 @@ mod tests { .expect("canonical user item"), format!("消息 {client_turn_id}"), None, + None, FIXED_AT_MS, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs index 2785c87cb..18425b202 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -35,6 +35,8 @@ pub(crate) struct PendingDirectTurn { /// 入队检查产出的 prompt:放行不重算。 pub(crate) prompt: String, pub(crate) creation_type: Option, + /// 这一轮的运行埋点句柄:由前端在入队时生成,放行开跑时用同一份身份。 + pub(crate) analytics_attempt_id: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, } @@ -49,6 +51,7 @@ impl PendingDirectTurn { user_item: DirectCodexUserItem, prompt: String, creation_type: Option, + analytics_attempt_id: Option, at: u64, ) -> Result { let canonical_user_item = serde_json::to_value(&user_item)?; @@ -58,6 +61,7 @@ impl PendingDirectTurn { canonical_user_item, prompt, creation_type, + analytics_attempt_id, at, }) } @@ -124,6 +128,7 @@ mod tests { user_item("生成一个游戏", "direct-codex:turn-1:user"), "生成一个游戏".to_string(), creation_type.map(str::to_string), + None, 1_700_000_000_123, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index a4649938e..9140d232c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -215,7 +215,7 @@ impl DirectThreadRequestKind { /// 一条待发消息离开队列的原因。 /// -/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 /// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -223,7 +223,7 @@ impl DirectThreadRequestKind { pub(crate) enum DirectQueueRemovalReason { /// 用户取消了这条待发消息。 Cancelled, - /// 放行:这条待发消息已经接单并成为回合。 + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 Dispatched, } @@ -293,7 +293,7 @@ impl DirectTurnFailure { pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs deleted file mode 100644 index fce538000..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。 -//! -//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、 -//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算, -//! 都没写时由 `Drop` 补一条 `host-dropped`。 -//! -//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未 -//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见 -//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - -use uuid::Uuid; - -use super::{ - accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms, - DirectTurnError, DirectTurnTerminal, -}; - -/// 一次接单的占用。持有它就代表这一轮还没收口。 -/// -/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败) -/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾, -/// 新回合不可能插到中间。 -pub(crate) struct DirectTurnReservation { - thread_id: String, - token: String, - user_item_id: Option, -} - -impl DirectTurnReservation { - /// 接单:登记占用并发出逻辑回合开始事件。 - /// - /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 - /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` - /// 是两件事:前者来自调用方,后者只活在这个进程里。 - /// - /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` - /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 - /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 - pub(crate) fn accept( - thread_id: &str, - client_turn_id: &str, - user_item_id: Option<&str>, - ) -> Result { - let token = Uuid::new_v4().to_string(); - accept_direct_thread_turn( - thread_id, - &token, - client_turn_id, - user_item_id, - direct_tool_call_now_ms(), - ) - .map_err(|existing| DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing, - incoming_invocation_id: client_turn_id.to_string(), - })?; - Ok(Self { - thread_id: thread_id.to_string(), - token, - user_item_id: user_item_id.map(str::to_string), - }) - } - - pub(crate) fn thread_id(&self) -> &str { - &self.thread_id - } - - /// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 - /// - /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { - complete_direct_thread_turn_if_reserved( - &self.thread_id, - &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), - ) - } -} - -impl Drop for DirectTurnReservation { - fn drop(&mut self) { - // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{ - consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, - }; - - /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 - fn watch(thread_id: &str) -> String { - let bootstrap = subscribe_direct_thread(thread_id); - let _ = consume_direct_thread(&bootstrap.subscription_id); - bootstrap.subscription_id - } - - fn pending(subscription_id: &str) -> Vec { - consume_direct_thread(subscription_id) - .expect("consume") - .events - } - - fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { - events - .iter() - .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) - .collect() - } - - fn unique_thread(label: &str) -> String { - format!("accept-test-{label}-{}", Uuid::new_v4()) - } - - #[test] - fn accept_emits_a_logical_turn_started_and_holds_the_turn() { - let thread = unique_thread("started"); - let subscription = watch(&thread); - - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let events = pending(&subscription); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - DirectThreadEvent::TurnStarted { user_item_id, .. } => { - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected turn.started, got {other:?}"), - } - assert!(direct_thread_turn_is_active(&thread)); - drop(reservation); - } - - #[test] - fn a_second_accept_is_rejected_while_the_turn_is_open() { - let thread = unique_thread("busy"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 - assert_eq!(pending(&subscription).len(), 1); - - let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); - - assert!(matches!( - rejected, - Err(DirectTurnError::TurnAlreadyRunning { .. }) - )); - let events = pending(&subscription); - assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); - drop(reservation); - } - - /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 - /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, - /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 - #[test] - fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { - let thread = unique_thread("same-turn-conflict"); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - let DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } = &same_turn - else { - panic!("expected a concurrency rejection, got {same_turn:?}"); - }; - assert_eq!(existing_invocation_id, "turn-1"); - assert_eq!(incoming_invocation_id, "turn-1"); - assert!( - same_turn.to_string().contains("同一轮消息仍在处理中"), - "{same_turn}" - ); - - let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - assert!(matches!( - &other_turn, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" - )); - assert!( - other_turn.to_string().contains("另一条 Direct 客户端回合"), - "{other_turn}" - ); - drop(reservation); - } - - #[test] - fn drop_without_a_terminal_writes_a_host_dropped_terminal() { - let thread = unique_thread("drop"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped())); - assert!(!direct_thread_turn_is_active(&thread)); - - // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { - user_item_id, - failure: Some(failure), - .. - } => { - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected a failed terminal, got {other:?}"), - } - } - - #[test] - fn the_deep_terminal_wins_and_the_fallback_stays_silent() { - let thread = unique_thread("deep"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - // 深层收口:真正跑完这一轮的代码算出来的终态。 - let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), - 2_000, - ) - .with_user_item_id(Some("u-1")); - crate::agent::complete_direct_thread_turn(&thread, deep); - - assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), - "深层已收口时兜底不许再写" - ); - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) - ); - } - other => panic!("expected a terminal, got {other:?}"), - } - } - - #[test] - fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { - let thread = unique_thread("again"); - let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - drop(first); - - let second = - DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept"); - - assert!(direct_thread_turn_is_active(&thread)); - drop(second); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs new file mode 100644 index 000000000..689298c53 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs @@ -0,0 +1,357 @@ +//! DirectProject 的放行:把队首那条待发消息变成一轮真的回合。 +//! +//! 这个模块两件事,别再往里加第三件: +//! 1. [`DirectTurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; +//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(取调用身份 → 落盘用户条目 → 下发 → 跑回合)。 +//! +//! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 +//! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 +//! `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +use std::path::{Path, PathBuf}; + +use super::{ + append_direct_project_user_message_at, claim_direct_pending_turn, + complete_direct_thread_turn_if_reserved, direct_thread_id_for_project, direct_tool_call_now_ms, + redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, + DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTaonierActiveInvocationGuard, + DirectTurnError, DirectTurnTerminal, PendingDirectTurn, +}; + +/// 一次放行的占用。持有它就代表这一轮还没收口。 +/// +/// 生命周期由调用方决定:登记与开始事件已经由 [`kick_direct_queue_dispatch`] 在同一临界区里写好, +/// 这里只接住这一轮的终态出口,并随放行任务一起 drop(正常 / 失败 / panic 都走同一个出口)。 +pub(crate) struct DirectTurnReservation { + thread_id: String, + token: String, + user_item_id: Option, +} + +impl DirectTurnReservation { + /// 接管一次放行的占用:占用登记与 `turn.started` 已由 Thread Manager 的认领写好。 + pub(crate) fn resume(thread_id: &str, dispatched: &DirectDispatchedTurn) -> Self { + Self { + thread_id: thread_id.to_string(), + token: dispatched.token.clone(), + user_item_id: dispatched.pending.user_item_id(), + } + } + + /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 + /// + /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 + pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + complete_direct_thread_turn_if_reserved( + &self.thread_id, + &self.token, + terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + ) + } + + /// 测试用:直接开一轮占用,等价于"入队 + 认领",不经过 Tauri 命令与真实的用户条目。 + #[cfg(test)] + pub(crate) fn accept_for_test(thread_id: &str, client_turn_id: &str) -> Self { + let pending = PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "测试消息"}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + "测试消息".to_string(), + None, + None, + direct_tool_call_now_ms(), + ) + .expect("prepare pending turn"); + super::enqueue_direct_pending_turn(thread_id, pending).expect("enqueue test turn"); + let dispatched = claim_direct_pending_turn(thread_id).expect("claim test turn"); + Self::resume(thread_id, &dispatched) + } +} + +impl Drop for DirectTurnReservation { + fn drop(&mut self) { + // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 + let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + // 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。 + // panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。 + kick_direct_queue_dispatch(Path::new(&self.thread_id)); + } +} + +/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。 +/// +/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 +/// **占用释放**(正常 / 失败 / panic 共用,见 [`DirectTurnReservation`] 的 `Drop`)、**中止路径** +/// (守卫被兜底释放时那一轮不会再有人替它收尾)。 +/// +/// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; +/// 这两种情况都会在下一次释放时再踢。 +pub(crate) fn kick_direct_queue_dispatch(root: &Path) { + let thread_id = direct_thread_id_for_project(root); + let Some(dispatched) = claim_direct_pending_turn(&thread_id) else { + return; + }; + let reservation = DirectTurnReservation::resume(&thread_id, &dispatched); + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + run_dispatched_direct_turn(root, dispatched, reservation).await; + }); +} + +/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。 +/// +/// 这一段的每个失败都是**回合失败**:占用与开始事件已经写好,只走占用对象的终态出口,不再回到任何 +/// 命令的返回值上。 +async fn run_dispatched_direct_turn( + root: PathBuf, + dispatched: DirectDispatchedTurn, + reservation: DirectTurnReservation, +) { + let turn_id = dispatched.pending.client_turn_id.clone(); + // 调用身份整轮持有:付费美术、执行会话、MCP、校验、上下文预取都读它。这里拿不到身份说明这个 + // 项目上还有另一条调用没放(残留守卫 / 别处的入口),按回合失败说清原因;队列照常继续。 + let active_invocation = match DirectTaonierActiveInvocationGuard::enter(&root, &turn_id) { + Ok(guard) => guard, + Err(failure) => { + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + }; + // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = + append_direct_project_user_message_at(&root, &dispatched.pending.canonical_user_item) + { + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 + // 消息的助手回复,而且失败会被静默掉。 + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + &root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), + }; + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 + // 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。 + crate::agent::codex_app_server::emit_direct_thread_user_item( + &root, + &dispatched.pending.canonical_user_item, + ); + let capture = crate::analytics::gui::capture_writer_context(); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); + let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, + &dispatched.pending.prompt, + dispatched.pending.creation_type.as_deref(), + Some(&emitter), + Some(dispatched.pending.canonical_user_item.clone()), + capture, + dispatched.pending.analytics_attempt_id.as_deref(), + ) + .await; + match outcome { + Ok(reply) => { + // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 + emitter.emit("completed", Some("none"), Some(reply), None); + } + Err(error) => { + // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 + // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + } + } + // 调用身份先放,再让占用对象的 `Drop` 收尾并踢下一脚:顺序反过来就会出现"守卫还握着、队首却已经 + // 可以放行"的窗口——那一脚会被自己的守卫挡成一条假的回合失败。 + drop(active_invocation); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + consume_direct_thread, direct_thread_turn_is_active, enqueue_direct_pending_turn, + subscribe_direct_thread, DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, + PendingDirectTurn, + }; + use uuid::Uuid; + + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 + fn watch(thread_id: &str) -> String { + let bootstrap = subscribe_direct_thread(thread_id); + let _ = consume_direct_thread(&bootstrap.subscription_id); + bootstrap.subscription_id + } + + fn pending(subscription_id: &str) -> Vec { + consume_direct_thread(subscription_id) + .expect("consume") + .events + } + + fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { + events + .iter() + .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + .collect() + } + + /// 一条待发消息。放行路径只读它的身份与产物,所以这里用最小可用形状。 + fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + format!("消息 {client_turn_id}"), + None, + None, + direct_tool_call_now_ms(), + ) + .expect("prepare pending turn") + } + + /// 真实存在的项目目录不是这条用例的判据,用唯一的假路径当线程身份即可:认领与占用的语义只认 + /// 这个字符串。 + fn unique_thread(label: &str) -> String { + format!("dispatch-test-{label}-{}", Uuid::new_v4()) + } + + fn enqueue(thread_id: &str, client_turn_id: &str) { + enqueue_direct_pending_turn(thread_id, pending_turn(client_turn_id)).expect("enqueue"); + } + + /// 放行的占用只认**自己的 token**:认领写下的占用身份与这一轮绑死,别人的终态盖不上它。 + #[test] + fn a_foreign_token_cannot_close_the_turn() { + let thread = unique_thread("token"); + enqueue(&thread, "turn-1"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let owner = DirectTurnReservation::resume(&thread, &dispatched); + let foreign = DirectTurnReservation::resume( + &thread, + &DirectDispatchedTurn { + token: "foreign-token".to_string(), + pending: pending_turn("turn-1"), + }, + ); + + assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(direct_thread_turn_is_active(&thread)); + assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!direct_thread_turn_is_active(&thread)); + // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 + drop(foreign); + drop(owner); + } + + /// 深层终态先写,占用对象的兜底就闭嘴:一轮只许有一条终态。 + #[test] + fn the_deep_terminal_wins_and_the_fallback_stays_silent() { + let thread = unique_thread("deep"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let reservation = DirectTurnReservation::resume(&thread, &dispatched); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), + 2_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + crate::agent::complete_direct_thread_turn(&thread, deep); + + assert!( + !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + "深层已收口时兜底不许再写" + ); + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); + match completed[0] { + DirectThreadEvent::TurnCompleted { failure, .. } => { + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); + } + other => panic!("expected a terminal, got {other:?}"), + } + } + + /// 没有终态的 Drop 会补一条 `host-dropped`,并且**踢一脚**让队里排着的下一条接上。 + #[test] + fn dropping_a_reservation_closes_it_and_lets_the_queue_move_on() { + let thread = unique_thread("drop"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + enqueue(&thread, "turn-2"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let reservation = DirectTurnReservation::resume(&thread, &dispatched); + + drop(reservation); + + // 第一条按宿主丢弃收口,第二条已经接上:放行不等人,也不必等下一次用户动作。 + let events = pending(&subscription); + let terminal = events + .iter() + .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + .expect("第一条必须被收口"); + assert!( + matches!( + events.get(terminal), + Some(DirectThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) + if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + ), + "{events:?}" + ); + let next_started = events.iter().position(|event| { + matches!( + event, + DirectThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-2:user") + ) + }); + assert!( + next_started.is_some_and(|index| index > terminal), + "队列里排着的下一条必须在收口之后立刻放行:{events:?}" + ); + } + + /// 已经有回合在跑、或队列为空时,踢一脚什么都不做。 + #[test] + fn kicking_does_nothing_while_a_turn_is_open_or_the_queue_is_empty() { + let empty = unique_thread("empty"); + kick_direct_queue_dispatch(Path::new(&empty)); + assert!(!direct_thread_turn_is_active(&empty)); + + let busy = unique_thread("busy"); + enqueue(&busy, "turn-1"); + enqueue(&busy, "turn-2"); + let dispatched = claim_direct_pending_turn(&busy).expect("claim head"); + let reservation = DirectTurnReservation::resume(&busy, &dispatched); + + kick_direct_queue_dispatch(Path::new(&busy)); + + // 仍在跑的那一轮没有被顶掉:占用身份还是第一条。 + assert!(direct_thread_turn_is_active(&busy)); + drop(reservation); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 21684a950..774fa34aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -5,13 +5,13 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): -//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): +//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 //! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 //! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 //! -//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 //! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! //! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: @@ -38,7 +38,7 @@ const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 /// -/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -62,7 +62,7 @@ impl DirectCodexFailureStage { /// 宿主等不到模型回执时,撞的是哪一条上限。 /// -/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -227,7 +227,7 @@ pub(crate) enum DirectTurnFailureKind { TransportFailed, /// app-server 或上游明确拒绝了这次请求。 RequestRejected, - /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 EnvironmentNotReady, /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 TurnInterrupted, @@ -235,7 +235,7 @@ pub(crate) enum DirectTurnFailureKind { HostDropped, } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 /// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 @@ -385,7 +385,7 @@ impl DirectModelCallKind { )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { - // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── + // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 @@ -397,6 +397,10 @@ pub(crate) enum DirectTurnError { existing_invocation_id: String, incoming_invocation_id: String, }, + /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 + /// + /// 上限只落在宿主这一处(`MAX_PENDING_DIRECT_TURNS`),随载荷带出去,界面不自己数一份。 + QueueFull { limit: usize }, /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 ProjectRootUnanchored { cause: String }, /// 项目目录不存在或不是绝对路径。 @@ -407,13 +411,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), - /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 + /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 + /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── + // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -442,7 +446,7 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } -/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 /// /// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 /// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 @@ -450,14 +454,14 @@ pub(crate) enum DirectTurnError { #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnRejection { +pub(crate) struct DirectTurnEnqueueFailure { /// 结构化变体:界面按 `error.type` 分流,不解析文案。 pub(crate) error: DirectTurnError, /// 可展示文案(`Display` 的唯一出口)。 pub(crate) message: String, } -impl DirectTurnRejection { +impl DirectTurnEnqueueFailure { pub(crate) fn new(error: DirectTurnError) -> Self { Self { message: error.to_string(), @@ -467,12 +471,12 @@ impl DirectTurnRejection { } impl DirectTurnError { - /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 + /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 - /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, - /// 不看文案。 + /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 + /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 + /// 判据按变体分,不看文案。 /// /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 /// 就是同一件事留两份。 @@ -482,6 +486,7 @@ impl DirectTurnError { Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } + | Self::QueueFull { .. } | Self::TurnAlreadyRunning { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 @@ -644,6 +649,10 @@ impl fmt::Display for DirectTurnError { formatter, "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" ), + Self::QueueFull { limit } => write!( + formatter, + "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" + ), Self::TurnAlreadyRunning { existing_invocation_id, incoming_invocation_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 7c78d7215..5b01d1f9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -5,7 +5,7 @@ //! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; //! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 //! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_dispatch.rs` 的放行占用对象里: //! 这个模块只负责"什么算失败、原因怎么写"。 //! //! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 68b5c610d..4d96baa21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5567,6 +5567,29 @@ pub(crate) fn list_game_creator_direct_active_turns( list_direct_active_turns() } +/// 取消一条**还没放行**的待发消息。 +/// +/// 结果分三种:真的从队列移除了(并以 `queue.removed{cancelled}` 告知所有订阅者)、这一条已经被 +/// 放行(正在跑的那一轮就是它,不能按待发消息取消)、队列里本来就没有这个身份。判据只看队列与占用, +/// 不看界面状态——同一份事实源在宿主。 +#[tauri::command] +pub(crate) async fn remove_direct_project_pending_turn( + project_path: String, + client_turn_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let thread_id = direct_thread_id_for_project(root); + Ok(remove_direct_pending_turn( + &thread_id, + client_turn_id.trim(), + )) + }) + .await + .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? +} + #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( project_path: String, 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 e0ceeb6da..1d61f47ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2619,7 +2619,8 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, - chat_with_game_creator_direct_codex, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, preflight_web_game_creation, cancel_direct_codex_turn, select_game_creator_reasoning_effort, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index f0c1c847a..571227caa 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -946,7 +946,7 @@ function directCodexDiagnosticFailureParts( return { stageLabel, summary, hint, retryable: retryable === 'true' }; } -/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, @@ -962,10 +962,10 @@ function directCodexDiagnosticFailureDetail(message: string) { } /** - * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * **请求被拒**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 * * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 - * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 哪一步",而请求被拒是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 */ export function projectRuntimeVisibleRejectionError( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f8b9866b5..d8e02903e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -163,7 +163,7 @@ export function DirectProjectChatView({ turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 - // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目, + // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。入队窗口里还没有这一轮的条目, // 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index bbee4e368..232197e32 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -37,10 +37,10 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - directTurnRejectionNotice, - directTurnRejectionNoticeMessageId, - directTurnUnrecognizedRejectionNoticeText, - readDirectTurnRejection, + directTurnEnqueueFailureNotice, + directTurnEnqueueFailureNoticeMessageId, + directTurnUnrecognizedEnqueueFailureNoticeText, + readDirectTurnEnqueueFailure, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { @@ -103,7 +103,7 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(忙态、拒单提示、终止说明)——**不造用户消息** + * C 本地: 前端自己造(忙态、入队失败提示、终止说明)——**不造用户消息** * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) @@ -119,29 +119,29 @@ export type DirectProjectChatControllerProps = { * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** - * 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 - * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与拒单提示两种。 - * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令接单化」的后续更新), + * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与入队失败提示两种。 + * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令入队化」), * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 * 任何条目,只有 composer 忙态与状态行): - * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主认领之前不存在。 - * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 + - * append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立, - * 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。 + * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在。 + * 2. `invoke('enqueue_direct_codex_turn')`:Rust 走完入队检查 → **入队**(排进待发消息队列)→ + * **立刻返回**。命令返回只说明入队成立;放行(登记占用 + `turn.started` + 离开队列)由宿主 + * 在队首就绪时自己完成,整轮的结果不再从这条通道回来,入队失败则返回结构化的 typed 错误。 * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 - * 的用户气泡**第一次**出现在聊天区(发点在接单之后、起 codex 之前)。 + * 的用户气泡**第一次**出现在聊天区(发点在放行之后、起 codex 之前)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态并出队(权限被拒那种 - * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 + * 8. 命令收尾(`finally`):刷新清单;只在**入队失败**时放掉忙态并出队(权限被拒那种 + * 「本轮从未发出但要继续出队」的路径也在这里收口),入队成立的那一轮交给第 3 / 7 步。 * * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 @@ -213,7 +213,7 @@ export function useDirectProjectChatController({ const completionPendingRef = useRef(false); /** * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, - * 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 + * 入队返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 */ const pendingRunAnalyticsRef = useRef<{ clientTurnId: string; @@ -266,12 +266,12 @@ export function useDirectProjectChatController({ }, [turnBusy, currentTurnRunning, completedTurnCount]); /** - * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 + * 回合终态是队列放行与埋点结算的唯一出口(入队失败走命令那条路,见 `startTurn` 的收尾)。 * * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 * - * TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 + * TODO(发送队列):这条队列整体挪到宿主的待发消息队列(放行由 Thread Manager 自己驱动)。 */ useEffect(() => { if (!enabled) { @@ -318,10 +318,10 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主认领这一轮(`turn.started` 落进 reducer) + * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主放行这一轮(`turn.started` 落进 reducer) * 或这一轮明确没成立时放掉。 * - * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:接单化之后命令只等到接单就返回, + * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:入队化之后命令只等到入队就返回, * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 */ function beginTurnBusy() { @@ -336,8 +336,8 @@ export function useDirectProjectChatController({ } /** - * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算 - * 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。 + * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 + * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 */ function settlePendingRunAnalytics() { const pending = pendingRunAnalyticsRef.current; @@ -516,7 +516,7 @@ export function useDirectProjectChatController({ /** * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地忙态与错误收尾。 * - * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"接单窗口期聊天区没有这一 + * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"入队窗口期聊天区没有这一 * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ function startTurn(input: DirectProjectTurnInput) { @@ -582,14 +582,14 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - // 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` - // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 + // 收尾分两种:入队成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` + // 时出队与结算,见上面两个 effect);入队失败的那些路径没有任何事件会来,只能在这里收口。 if (!turnAccepted) { // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 endTurnBusy(); // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 - // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 + // 入队失败时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 if ( queueAdvance || (invoked && projectPathRef.current === nextProjectPath) @@ -606,7 +606,7 @@ export function useDirectProjectChatController({ nextProjectPath: string, input: DirectProjectTurnInput, ): Promise { - // 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。 + // 命令返回 `Ok` 只说明**入队成立**:整轮怎么收场只由 `turn.completed` 事件回答。 // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 let turnAccepted = false; try { @@ -619,7 +619,7 @@ export function useDirectProjectChatController({ clientTurnId: input.clientTurnId, runAnalytics, }; - await invoke('chat_with_game_creator_direct_codex', { + await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, userItem: input.userItem, @@ -629,11 +629,11 @@ export function useDirectProjectChatController({ turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { - // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 - // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 - const rejection = readDirectTurnRejection(error); - // 只有结构化拒单能证明"这一轮没接单"(拒单不产生回合事件、也就不会有埋点候选),句柄才 - // 只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,那时必须留着句柄等 + // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 + // 只剩"入队失败"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 + const rejection = readDirectTurnEnqueueFailure(error); + // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), + // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 if ( rejection && @@ -642,9 +642,9 @@ export function useDirectProjectChatController({ pendingRunAnalyticsRef.current = null; } if (rejection) { - const notice = directTurnRejectionNotice(rejection); + const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { - // 认得的前置条件 / 参数类拒单:写成与用户消息同级的提示,不占状态行、不写运行错误、 + // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 // 也不上报(用户自己就能改,上报只会变成噪声)。 if (projectPathRef.current === nextProjectPath) { onRuntimeError(''); @@ -652,7 +652,7 @@ export function useDirectProjectChatController({ role: 'assistant', text: notice, runtimeOwned: true, - messageId: directTurnRejectionNoticeMessageId( + messageId: directTurnEnqueueFailureNoticeMessageId( directCodexConversationMessageId(input.clientTurnId, 'user'), ), updatedAt: Date.now(), @@ -662,12 +662,12 @@ export function useDirectProjectChatController({ } } if (projectPathRef.current !== nextProjectPath) return false; - // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 + // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 拒单文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; - // 拒单不带阶段标签(这一轮没有开始),所以走拒单那一份映射。 + // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; + // 入队失败不带阶段标签(这一轮没有开始),所以走入队失败那一份映射。 const visibleMessage = rejection - ? directTurnUnrecognizedRejectionNoticeText(rejection) + ? directTurnUnrecognizedEnqueueFailureNoticeText(rejection) : projectRuntimeVisibleError( error instanceof Error ? error.message : String(error), '陶泥儿智能创作', @@ -675,15 +675,15 @@ export function useDirectProjectChatController({ ); if (projectPathRef.current !== nextProjectPath) return false; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 - // 本轮最后一条条目(唯一来源)。**拒单没有这条出口**——拒单不产生回合事件,聊天里那条乐观 - // 用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 - // 非结构化错误同样不写聊天:它可能发生在接单之后,说明由事件流负责。 + // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 + // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 + // 非结构化错误同样不写聊天:它可能发生在放行之后,说明由事件流负责。 if (rejection) { appendLocalMessage({ role: 'assistant', text: visibleMessage, runtimeOwned: true, - messageId: directTurnRejectionNoticeMessageId( + messageId: directTurnEnqueueFailureNoticeMessageId( directCodexConversationMessageId(input.clientTurnId, 'user'), ), updatedAt: Date.now(), diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index ed5df6dce..b884211e9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -15,8 +15,8 @@ import type { * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → - * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 - * 等到接单就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 + * 宿主放行这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。入队化之后命令只 + * 等到入队就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 5547facbf..62d80f344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -2,7 +2,7 @@ import type { ChatMessage } from '../../../../app/types'; import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; -import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; +import type { DirectTurnEnqueueFailure } from '../generated/DirectTurnEnqueueFailure'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; @@ -63,14 +63,14 @@ export function directCodexConversationMessageId( } /** - * 命令的**拒单**载荷(Rust 侧 `DirectTurnRejection`):结构化变体 + 宿主生成的文案。 + * 命令的**入队失败**载荷(Rust 侧 `DirectTurnEnqueueFailure`):结构化变体 + 宿主生成的文案。 * * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 * `error.type`——文案是给人看的,不参与任何判断。 */ -export function readDirectTurnRejection( +export function readDirectTurnEnqueueFailure( error: unknown, -): DirectTurnRejection | null { +): DirectTurnEnqueueFailure | null { if (!error || typeof error !== 'object') return null; const candidate = error as { error?: unknown; message?: unknown }; const variant = candidate.error; @@ -78,12 +78,12 @@ export function readDirectTurnRejection( const type = (variant as { type?: unknown }).type; if (typeof type !== 'string' || !type) return null; if (typeof candidate.message !== 'string') return null; - return candidate as DirectTurnRejection; + return candidate as DirectTurnEnqueueFailure; } /** - * 认得的拒单(前置条件不满足 / 用户参数无效)→ 与用户消息同级的提示文案;认不得的返回 `null`, - * 由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 + * 认得的入队失败(前置条件不满足 / 用户参数无效 / 队列已满)→ 与用户消息同级的提示文案; + * 认不得的返回 `null`,由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 * * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 @@ -95,49 +95,47 @@ export function readDirectTurnRejection( * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 * 调用方当成"有提示")。 */ -export function directTurnRejectionNotice( - rejection: DirectTurnRejection, +export function directTurnEnqueueFailureNotice( + failure: DirectTurnEnqueueFailure, ): string | null { - switch (rejection.error.type) { + switch (failure.error.type) { case 'clientTurnIdMissing': case 'clientTurnIdMalformed': + case 'queueFull': case 'turnAlreadyRunning': case 'projectRootUnanchored': case 'projectRootUnusable': case 'permissionRejected': case 'inputRejected': case 'contentEmpty': - return rejection.message.trim() || null; + return failure.message.trim() || null; default: return null; } } /** - * 拒绝提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, * 两条通道永远不会合并成一条。 */ -export function directTurnRejectionNoticeMessageId(userItemId: string) { +export function directTurnEnqueueFailureNoticeMessageId(userItemId: string) { return `${userItemId}:rejected`; } /** - * **认不出的**拒单(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 + * **认不出的**入队失败(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 * - * 这两类拒单不产生 `turn.completed`(拒单没有接单),而本地也不再造用户气泡,所以这一轮在聊天区里 - * 本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。它带自己的身份 - * (`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 + * 这两类失败不产生 `turn.completed`(入队失败的这一轮从未成立),而本地也不再造用户气泡,所以 + * 这一轮在聊天区里本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。 + * 它带自己的身份(`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 * - * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` + * 文案不能原样用宿主给的 `message`:这类失败的 `message` 是宿主的收口文案(带 `stage=` / `code=` * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, * 绝不把内部字段塞进聊天。 */ -export function directTurnUnrecognizedRejectionNoticeText( - rejection: DirectTurnRejection, +export function directTurnUnrecognizedEnqueueFailureNoticeText( + failure: DirectTurnEnqueueFailure, ): string { - return projectRuntimeVisibleRejectionError( - rejection.message, - '陶泥儿智能创作', - ); + return projectRuntimeVisibleRejectionError(failure.message, '陶泥儿智能创作'); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 199a335bb..85d58327e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -89,7 +89,7 @@ export type DirectThreadChatState = { * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 - * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后 + * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(放行后 * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, * 批量到达也一样看得见。 */ @@ -324,7 +324,7 @@ export function reduceDirectThreadEvent( const eventAt = readDirectThreadEventAt(event); // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, - // 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的 + // 哪怕它落在同一秒。开始事件由 Thread Manager 在放行时发一次,线上不再有同一轮的 // 重复起点,所以这里不做"保留第一次"的兼容。 const turnStartedAt = eventAt; // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 1ab3d433e..5c55c19b9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -6,7 +6,7 @@ * * **回合归属只认身份**:开口用户条目的 canonical `itemId`(`direct-codex:{clientTurnId}:user`) * 就是这一轮的回合身份,本轮用户条目与失败说明按它归进同一轮。本地只保留"说明"类消息 - * (拒单提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 + * (入队失败提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 */ import type { ChatMessage } from '../../../../app/types'; @@ -64,11 +64,11 @@ export type DirectChatBlock = * 回显条目的 `at` 还是宿主落盘 / 观测时间(晚于用户真实发送)。 * * 这里曾经有过第三个态 `awaiting-start`(本地已发出、宿主还没认领):它只服务本地乐观用户气泡。 - * 气泡已按"回合只认宿主条目"删除,所以"接单窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 - * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令接单化」的后续更新)。 + * 气泡已按"回合只认宿主条目"删除,所以"入队窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 + * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令入队化」)。 * * 两个容易读错的地方: - * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; + * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到入队,宿主确认这一轮靠的是开始事件; * 所以"命令还没回来"不是任何展示态依据,只有显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 @@ -105,7 +105,7 @@ export type DirectChatTurn = { type DirectChatTurnEntries = { key: string; entries: DirectChatEntry[]; - /** 本地说明(拒单提示、壳层 `announce`):不是回合条目,按挂点归组。 */ + /** 本地说明(入队失败提示、壳层 `announce`):不是回合条目,按挂点归组。 */ notices: ChatMessage[]; /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ nativeRunning: boolean; @@ -157,7 +157,7 @@ function blockFromLocalMessage( ): DirectChatBlock | null { const text = message.text.trim(); if (!text) return null; - // 本地消息只剩"说明"一种(拒单提示、壳层 `announce`):用户消息一律来自宿主条目。 + // 本地消息只剩"说明"一种(入队失败提示、壳层 `announce`):用户消息一律来自宿主条目。 return { kind: 'assistant', key: message.messageId ?? `local:${index}`, @@ -198,7 +198,7 @@ function directEntryTurnKey(entry: DirectChatEntry): string { * 其余条目(工具、思考、正文)不带身份,跟着当前回合走。 * * 本地消息只剩说明一种,用户消息一律来自宿主条目(本地乐观气泡已删,见 ADR「DirectProject命令 - * 接单化」的后续更新):带身份的说明(拒单提示,id 形如 `…:user:rejected`)说的是"这一轮从没 + * 入队化」的后续更新):带身份的说明(入队失败提示,id 形如 `…:user:rejected`)说的是"这一轮从没 * 成立过",不属于任何回合,自己在会话末尾开一组;不带头身份的是壳层 `announce`,挂到当前回合末 * 尾。同身份的本地消息不重复渲染:条目赢。 * @@ -269,7 +269,7 @@ export function buildDirectChatTurns({ // 本地不再造用户消息(乐观气泡已删):万一还来了一条,既不进聊天区、也不开回合。 if (message.role === 'user') return; if (message.messageId && entryIds.has(message.messageId)) return; - // 带身份的本地说明(拒单提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 + // 带身份的本地说明(入队失败提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 // 上一轮(那会被读成上一轮的问题)。它自己在会话末尾开一组提示:没有用户条目的回合不会凭空 // 多出一条耗时文案(`startedAt` 拿不到时终态文案整条隐藏)。 if (message.messageId) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts index e9b8f2355..a7040d769 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts @@ -3,7 +3,7 @@ /** * 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 * - * 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 + * 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 */ export type DirectCodexFailureStage = | 'art-preparation' diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts index b76ba3800..1c39e2058 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts @@ -2,7 +2,7 @@ import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; /** - * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 * * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts index 90f8b8247..c6c619049 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts @@ -3,7 +3,7 @@ /** * 一条待发消息离开队列的原因。 * - * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 + * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 * (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 */ export type DirectQueueRemovalReason = 'cancelled' | 'dispatched'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 630a41e95..55c0b6a94 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -35,7 +35,7 @@ export type DirectThreadEvent = | { type: 'turn.started'; /** - * 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 * `turn/start` 的时刻)。 */ at?: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts index b0ad58f44..83f2fe79b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts @@ -3,6 +3,6 @@ /** * 宿主等不到模型回执时,撞的是哪一条上限。 * - * 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 + * 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 */ export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts similarity index 85% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts index ae3ce8aa9..3e3496ab8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts @@ -2,13 +2,13 @@ import type { DirectTurnError } from './DirectTurnError'; /** - * 拒单载荷:命令边界交给前端的**结构化拒绝**。 + * 入队失败载荷:命令边界交给前端的**结构化入队失败**。 * * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 */ -export type DirectTurnRejection = { +export type DirectTurnEnqueueFailure = { /** * 结构化变体:界面按 `error.type` 分流,不解析文案。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts index 7e14e32c0..94d69d66a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -14,6 +14,7 @@ export type DirectTurnError = existingInvocationId: string; incomingInvocationId: string; } + | { type: 'queueFull'; limit: number } | { type: 'projectRootUnanchored'; cause: string } | { type: 'projectRootUnusable' } | { type: 'permissionRejected'; policyDetail: string } diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index c1a1ef1a1..0d6074e4e 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -462,8 +462,8 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); }); - test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { - // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + test('请求被拒的文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而请求被拒是"这一轮没有开始":阶段只会是默认值, // 套上去会把没发生的事讲成发生了。 expect( projectRuntimeVisibleRejectionError( @@ -481,7 +481,7 @@ describe('Agent Runtime Provider 状态投影', () => { ), ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; - // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + // 目录锚不定的请求被拒在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 expect( projectRuntimeVisibleRejectionError( '无法锚定 Direct 调用项目目录:拒绝访问', diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 9008e4cb9..51c3db79e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -400,7 +400,7 @@ export function registerChatComposerControlTests() { // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, // 整轮只 invoke 一次、埋点只结算一次。 const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { throw new Error('authentication-required'); }, }); @@ -414,22 +414,22 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); const attempts = invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); - // 这一轮没有接单,不会有回合终态事件来驱动结算:埋点一次也不结算。 + // 这一轮没有入队,不会有回合终态事件来驱动结算:埋点一次也不结算。 expect( invoke.mock.calls.filter( ([command]) => command === 'settle_direct_run_analytics', ), ).toHaveLength(0); - // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 + // 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), @@ -441,10 +441,10 @@ export function registerChatComposerControlTests() { }); it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { - // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 + // 宿主 / 环境事实的入队失败没有放行、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 // (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。 const { surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { throw { error: { type: 'environmentNotReady', @@ -470,7 +470,7 @@ export function registerChatComposerControlTests() { 'direct-codex-failure', ); expect(conversation.textContent ?? '').not.toContain('stage='); - // 拒单没接单:忙碌态要放掉,用户能直接重发。 + // 入队失败没有成立:忙碌态要放掉,用户能直接重发。 await waitFor(() => { expect( within(surface).getByRole('button', { name: '发送' }), @@ -480,8 +480,8 @@ export function registerChatComposerControlTests() { it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const { invoke, surface, harness } = await openDirectCodexSurface({ - // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 - chat_with_game_creator_direct_codex: () => { + // 命令只入队(放行由 Thread Manager 自己完成),随后立刻返回。 + enqueue_direct_codex_turn: () => { harness.emitDirectThreadEvents({ type: 'turn.started' }); return Promise.resolve(null); }, @@ -490,7 +490,7 @@ export function registerChatComposerControlTests() { await submitDirectTurn(surface, composer, '第一条消息'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('第一条消息'), ); }); @@ -528,7 +528,7 @@ export function registerChatComposerControlTests() { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); - // 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。 + // 队列放行听的是**回合终态**,不是命令返回:入队之后命令早就回来了。 act(() => { harness.emitDirectThreadEvents({ type: 'turn.completed', @@ -537,13 +537,13 @@ export function registerChatComposerControlTests() { }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('第三条消息'), ); }); // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 const sentTexts = invoke.mock.calls - .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .filter(([command]) => command === 'enqueue_direct_codex_turn') .map(([, args]) => directTurnInputText(args)); expect(sentTexts).toEqual(['第一条消息', '第三条消息']); @@ -562,7 +562,7 @@ export function registerChatComposerControlTests() { const pending: Array<{ resolve: (value: string) => void }> = []; let userItemId = ''; const { invoke, surface, harness } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: (args) => { + enqueue_direct_codex_turn: (args) => { userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; return new Promise((resolve) => { pending.push({ resolve }); @@ -576,7 +576,7 @@ export function registerChatComposerControlTests() { // 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。 await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('窗口期的消息'), ); }); @@ -635,7 +635,7 @@ export function registerChatComposerControlTests() { null; const { surface } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: ( + enqueue_direct_codex_turn: ( args: Record | undefined, ) => { // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来, @@ -690,14 +690,14 @@ export function registerChatComposerControlTests() { }); it('keeps the composer moving when the host fails the turn right after accepting it', async () => { - // 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 + // 落盘失败发生在**放行之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 // 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。 const seen: string[] = []; let harness: ReturnType | null = null; const { surface } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: ( + enqueue_direct_codex_turn: ( args: Record | undefined, ) => { const text = directTurnInputText(args); @@ -759,7 +759,7 @@ export function registerChatComposerControlTests() { deferredPolicies.push(resolve); }); }, - chat_with_game_creator_direct_codex: () => + enqueue_direct_codex_turn: () => new Promise((resolve) => { pending.push({ resolve }); }), @@ -790,7 +790,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('后面那条'), ); }); @@ -809,7 +809,7 @@ export function registerChatComposerControlTests() { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface, harness } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: () => + enqueue_direct_codex_turn: () => new Promise((resolve) => { pending.push({ resolve }); }), @@ -831,7 +831,7 @@ export function registerChatComposerControlTests() { expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(0); @@ -843,13 +843,13 @@ export function registerChatComposerControlTests() { }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('恢复后排队的消息'), ); }); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); @@ -860,9 +860,9 @@ export function registerChatComposerControlTests() { it('terminates the running turn and returns the composer to the idle state', async () => { const { invoke, path, surface, harness } = await openDirectCodexSurface({ - // 命令只接单:接单成立(开始事件已由 Thread Manager 下发)后它立刻返回,"这一轮还在跑" + // 命令只入队:入队成立(放行与开始事件都由 Thread Manager 自己完成)后它立刻返回,"这一轮还在跑" // 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。 - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { harness.emitDirectThreadEvents({ type: 'turn.started' }); return Promise.resolve(null); }, @@ -876,7 +876,7 @@ export function registerChatComposerControlTests() { await submitDirectTurn(surface, composer, '做一个小游戏'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('做一个小游戏'), ); }); @@ -944,7 +944,7 @@ export function registerChatComposerControlTests() { // 只影响后续回合:当前没有发出任何新一轮直接对话。 expect( invoke.mock.calls.some( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toBe(false); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 9fcfbbf19..078fe66a0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1376,7 +1376,7 @@ export function registerHomeProjectCreationTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { chatHarness.completeDirectThreadTurn({ turnId: String(args?.clientTurnId ?? ''), prompt: '你好,今天多少号', @@ -1457,7 +1457,7 @@ export function registerHomeProjectCreationTests() { planning: false, projectsRoot: null, }); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), @@ -1474,7 +1474,7 @@ export function registerHomeProjectCreationTests() { invoke.mock.calls .map(([command]) => String(command)) .filter((command) => command.includes('direct_codex')), - ).toEqual(['chat_with_game_creator_direct_codex']); + ).toEqual(['enqueue_direct_codex_turn']); // 首页那条直连命令没有出现,界面也已经交给项目对话面板。 expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull(); @@ -1525,7 +1525,7 @@ export function registerHomeProjectCreationTests() { }); return '角色参考游戏'; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { chatHarness.completeDirectThreadTurn({ turnId: String(args?.clientTurnId ?? ''), prompt: '按这个角色做游戏', @@ -1578,32 +1578,29 @@ export function registerHomeProjectCreationTests() { // 首轮需求由 DirectProject 聊天容器认领:它要先读到项目清单(项目身份就绪)才发送, // 所以这里等一等,不能在「工作台已出现」的同一帧断言。 await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath: automaticProjectPath, - creationType: 'game', - clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), - userItem: { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', - role: 'user', - // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 - content: [ - { type: 'input_text', text: '按这个角色做游戏' }, - { - type: 'agc_attachment_reference', - name: '角色参考.png', - mediaType: 'image/png', - size: attachment.size, - localPath: 'assets/uploads/reference.png', - status: 'imported', - }, - ], - }, + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { + projectPath: automaticProjectPath, + creationType: 'game', + clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 + content: [ + { type: 'input_text', text: '按这个角色做游戏' }, + { + type: 'agc_attachment_reference', + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }, - ); + }); }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1617,11 +1614,11 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(2); }); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath: automaticProjectPath, clientTurnId: expect.any(String), analyticsAttemptId: expect.any(String), @@ -1634,7 +1631,7 @@ export function registerHomeProjectCreationTests() { }); // 第二次 invoke 就是这条后续消息:回合入参里没有客户端投影文本可供筛选。 const followUpPayload = invoke.mock.calls - .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .filter(([command]) => command === 'enqueue_direct_codex_turn') .at(-1)?.[1] as Record | undefined; // 后续这一轮没有附件:canonical content 里只有文本 part。 expect( @@ -1929,7 +1926,7 @@ export function registerHomeProjectCreationTests() { '本轮用户附件', ); expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.anything(), ); expect(invoke).not.toHaveBeenCalledWith( @@ -2069,7 +2066,7 @@ export function registerHomeProjectCreationTests() { 'references/角色参考.png', ); expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.anything(), ); expect(invoke).not.toHaveBeenCalledWith( @@ -2179,7 +2176,7 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return currentManifest; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { return directTurn; } return runtimeHarness.invoke(command, args); @@ -2203,7 +2200,7 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.objectContaining({ projectPath }), ); expect(runtimeHarness.listen).toHaveBeenCalledWith( @@ -2269,7 +2266,7 @@ export function registerHomeProjectCreationTests() { }); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); } @@ -2338,7 +2335,7 @@ export function registerHomeProjectCreationTests() { 'DirectProject must not use browser conversation writer', ); } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { const clientTurnId = String(args?.clientTurnId ?? ''); persistedMessages.push( { @@ -2372,26 +2369,23 @@ export function registerHomeProjectCreationTests() { ); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath, - clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), - userItem: { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '继续修改已有项目' }], - }, + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { + projectPath, + clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '继续修改已有项目' }], }, - ); + }); }); // 首轮只发一轮 DirectProject 回合:其余命令都由上面的 stub 逐条接住, // 未列出的命令(含浏览器侧会话写入)在 stub 里直接抛错。 expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); @@ -2427,7 +2421,7 @@ export function registerHomeProjectCreationTests() { policy: { deniedCommands: [], confirmCommands: [] }, }; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { const clientTurnId = String(args?.clientTurnId ?? ''); expect(args).toEqual({ projectPath, @@ -2511,7 +2505,7 @@ export function registerHomeProjectCreationTests() { ).not.toBeNull(); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); @@ -3387,7 +3381,7 @@ export function registerRecentProjectsTests() { manifest, }; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { return '收到,开始搭建。'; } return supervisorHarness.invoke(command, args); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 36050745d..45745ed82 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,9 +1,9 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { directCodexPolicyRetryInput, - directTurnRejectionNotice, - directTurnUnrecognizedRejectionNoticeText, - readDirectTurnRejection, + directTurnEnqueueFailureNotice, + directTurnUnrecognizedEnqueueFailureNoticeText, + readDirectTurnEnqueueFailure, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { createGameCreationAppManifest, @@ -22,7 +22,7 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { - // 拒单是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 + // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 const concurrent = { error: { type: 'turnAlreadyRunning' as const, @@ -31,34 +31,36 @@ export function registerProjectConversationTests() { }, message: '同一轮消息仍在处理中', }; - expect(readDirectTurnRejection(concurrent)?.error.type).toBe( + expect(readDirectTurnEnqueueFailure(concurrent)?.error.type).toBe( 'turnAlreadyRunning', ); - expect(directTurnRejectionNotice(concurrent)).toBe('同一轮消息仍在处理中'); + expect(directTurnEnqueueFailureNotice(concurrent)).toBe( + '同一轮消息仍在处理中', + ); // 认得的参数 / 前置条件类都给同级提示。 expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'contentEmpty' }, message: '聊天内容不能为空', }), ).toBe('聊天内容不能为空'); // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'environmentNotReady', detail: '连不上 app-server' }, message: '环境未就绪', }), ).toBeNull(); expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'hostStateUnavailable', detail: '账本损坏' }, message: '宿主状态取不到', }), ).toBeNull(); - // 认不出的拒单在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / + // 认不出的入队失败在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 expect( - directTurnUnrecognizedRejectionNoticeText({ + directTurnUnrecognizedEnqueueFailureNoticeText({ error: { type: 'environmentNotReady', detail: '连不上 app-server' }, message: 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', @@ -68,19 +70,21 @@ export function registerProjectConversationTests() { ); // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 expect( - directTurnUnrecognizedRejectionNoticeText({ + directTurnUnrecognizedEnqueueFailureNoticeText({ error: { type: 'hostStateUnavailable', detail: '账本损坏' }, message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', }), ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); // 不是这份结构(旧字符串、Error、裸对象)一律不认。 expect( - readDirectTurnRejection('codex-app-server-error:unauthorized'), + readDirectTurnEnqueueFailure('codex-app-server-error:unauthorized'), ).toBeNull(); - expect(readDirectTurnRejection(new Error('boom'))).toBeNull(); - expect(readDirectTurnRejection({ error: {}, message: 'x' })).toBeNull(); + expect(readDirectTurnEnqueueFailure(new Error('boom'))).toBeNull(); expect( - readDirectTurnRejection({ error: { type: 'contentEmpty' } }), + readDirectTurnEnqueueFailure({ error: {}, message: 'x' }), + ).toBeNull(); + expect( + readDirectTurnEnqueueFailure({ error: { type: 'contentEmpty' } }), ).toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 2b8982e8e..4b385780e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -667,7 +667,7 @@ describe('DirectProject 聊天 reducer', () => { }); it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => { - // 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮 + // 宿主在放行时**只发一次** `turn.started`(Thread Manager 的认领动作),线上不存在"同一轮 // 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer // 按事件顺序照实收下,不替它编一个更早的起点。 const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ @@ -680,7 +680,7 @@ describe('DirectProject 聊天 reducer', () => { it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => { // 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次 - // consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。 + // consume 里开始并结束(放行后立刻失败),那时下降沿永远不会出现。 const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f3ae0325d..06ce3bd38 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -323,7 +323,7 @@ describe('DirectProject 聊天分区', () => { }); it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => { - // 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在, + // 入队窗口(本地已发出、宿主还没放行)不再是展示态:这一轮在聊天区里根本不存在, // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。 const windowTurns = buildDirectChatTurns({ entries: [] }); expect(windowTurns).toEqual([]); From e1780b21b05321b0a4299772ed1bc9e8af8ac825 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:40 +0800 Subject: [PATCH 05/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20dev=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=20sccache=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start-tauri-dev.mjs 启动 Tauri CLI 前复用 scripts/dev.mjs 的 buildLocalRustProcessEnv,本地 Rust wrapper 由脚本决定而不是本机 Cargo 配置 - 新增 buildTauriDevProcessEnv 组装 dev 端点环境并导出,供测试直接断言 - 补 3 条用例:配置为 sccache 时改用 /usr/bin/env 直连 rustc、未配置时清空两个 wrapper 变量、自定义 wrapper 保留且不改写调用方 env - pitfalls 与开发运维文档补记 AGC dev 入口的 wrapper 口径 --- .../scripts/start-tauri-dev.mjs | 18 +++++-- .../tests/start-tauri-dev.test.ts | 52 +++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index e619c7123..3d72403ae 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,6 +1,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { defaultEditorFeatures, withDefaultCargoFeatures, @@ -75,6 +76,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) { }); } +/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 +/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态 +/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用 +/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。 +function buildTauriDevProcessEnv(endpoint, env = process.env) { + return buildLocalRustProcessEnv({ + ...withAgcDevEndpointEnv(endpoint, env), + [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, + }); +} + async function runTauriDev( argv = process.argv.slice(2), { @@ -135,10 +147,7 @@ async function runTauriDev( endpoint.url, ); child = spawnCli(tauriArguments, { - env: { - ...withAgcDevEndpointEnv(endpoint), - [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, - }, + env: buildTauriDevProcessEnv(endpoint), }); const childResult = waitForCli(child); const outcome = await Promise.race([ @@ -225,6 +234,7 @@ function isDirectModuleExecution() { export { buildTauriArguments, + buildTauriDevProcessEnv, isDirectModuleExecution, runTauriDev, spawnTauriCli, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 7b8308410..dd83b82a8 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -12,6 +12,7 @@ import { } from '../scripts/start-dev-stack.mjs'; import { buildTauriArguments, + buildTauriDevProcessEnv, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -300,3 +301,54 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 进程环境', () => { + const posixTest = process.platform === 'win32' ? test.skip : test; + + // 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量 + // 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。 + posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + RUSTC_WRAPPER: 'sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }); + + expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('未显式配置 wrapper 时清空两个变量', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + CARGO_TERM_COLOR: 'never', + }); + + expect(env.RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_TERM_COLOR).toBe('never'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }; + const env = buildTauriDevProcessEnv(testEndpoint, input); + + expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port)); + expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20e41dd15..712b30ac6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2973,9 +2973,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 - 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。 -- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 +- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 - 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 -- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2bf1b55cf..0d39c0ee9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -80,10 +80,12 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。 +`npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From e02811588c3b2de758829878f59b821dc1beca64 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:42 +0800 Subject: [PATCH 06/60] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=20staging=20=E5=BD=92=E4=BD=8D?= =?UTF-8?q?=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md:记录根因证据(tauri-build 把随包资源登记成 build script 输入)、准备步骤合同、构建脚本退化边界、入口接线、验收判据、风险与里程碑拆分 - docs/README.md 挂入文档索引 --- docs/README.md | 1 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md diff --git a/docs/README.md b/docs/README.md index 20a519f46..6ea1a2b7e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。 +- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md new file mode 100644 index 000000000..0ec2225b0 --- /dev/null +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -0,0 +1,194 @@ +# AGC 随包资源 staging 归位技术方案 + +状态:待评审(方案草案,评审通过前不进入实现) +日期:2026-09-26 +范围:AGC 客户端(`apps/ai-game-creator-shell`)随包资源的生成、校验与打包链路 +关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md` + +## 1. 一句话目标 + +把「随包资源」从 build script 的**产物**改回它的**输入**:由准备步骤在 `tauri dev|build` 之前一次性 staging,`build.rs` 退化为校验者,构建期不再向 `src-tauri/resources/**` 写任何文件。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. `build.rs` 的输入集合里不再包含任何「本次构建会写」的文件,`cargo` 在源码不变时稳定 fresh。 +2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 +3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 +4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 +5. 删除症状层补丁(整目录重建的规避、`prune_staging`、`STAGED_*` 幂等判断、`.taurignore` 的 staging 条目)。 + +### 2.2 非目标 + +1. 不改发布渠道、版本号机制、签名与上传流程。 +2. 不改运行时资源解析顺序与完整性校验语义(`codex_cli.rs`、`plugin_host.rs`、`environment_check.rs`、`editor_adapters.rs` 保持行为)。 +3. 不统一 `server-rs` 与 `src-tauri` 两个 workspace,不改 target 布局。 +4. 不为 Linux 增加客户端产物(Linux 上五条 staging 全为 no-op,见 §3.6)。 +5. 不改 Windows/macOS 之外的平台支持面。 + +## 3. 现状与证据 + +### 3.1 build script 目前负责五条 staging + +`apps/ai-game-creator-shell/src-tauri/build.rs` 的 `main()` 前段依次执行(`build.rs:225-229`): + +| # | 步骤 | 动作类型 | 平台门槛 | 目标路径 | +|---|---|---|---|---| +| 1 | `stage_bundled_codex_cli` | 纯复制(内容+权限比对) | Windows / macOS | `resources/codex/**` | +| 2 | `prepare_unity_editor_helper` | **外部工具链**(`powershell -File build.ps1`,需 .NET 10 SDK + VS C++ x64) | Windows + unity feature | `resources/plugins/agc-unity-editor/**` | +| 3 | `prepare_godot_editor_extension` | **外部工具链**(`powershell -File build.ps1`,需 CMake ≥ 3.25 + VS17 2022 + Python 3) | Windows + godot feature | `resources/plugins/agc-godot-editor/native/gdextension/**` | +| 4 | `stage_plugin_workspace` | 复制 + 清残留 | Windows / macOS | `resources/plugins/**` | +| 5 | `stage_cocos_editor_payload` | 复制(同一 cargo 构建产出的 cdylib) | Windows | `resources/plugins/agc-cocos-editor/native/payload/**` | + +(Prompt Bundle 生成代码写 `OUT_DIR`,属正确形态;Node 运行时已由 `scripts/stage-node-runtime.mjs` 在发布前一次性 staging,是本次改造的既有先例。) + +### 3.2 这些写入为什么会让 build script 永远失效 + +`tauri-build` 会对 `bundle.resources` 里的**每个文件**发 `cargo:rerun-if-changed`,并把它拷进 target(`tauri-build-2.6.3/src/lib.rs:88-93`)。我们的三份 tauri 配置把 `resources/codex/**` 与 `resources/plugins` 列进了 `bundle.resources`(`tauri.windows.conf.json:7-15`、`tauri.macos.conf.json:8-22`;基线配置故意不含,见 `check-config.mjs:1377-1383`)。 + +于是「构建期写的文件」与「build script 声明的输入」是同一批: + +```text +staging 写 resources/** → tauri-build 把同一批文件登记成输入 → mtime 变化 + ↑ ↓ + └────────────── cargo 判 build script stale,重跑脚本 ←──────┘ +``` + +判据是「输入文件 mtime 比 build script 的输出新」,与目录位置无关:把 staging 搬到 `target/` 也躲不开——登记的是配置里写的那些路径,只要构建期写它们,照样自触发。所以关键不是「产物放哪」,而是「**构建期有没有人写这些被登记的文件**」。 + +### 3.3 已发生的故障 + +| 症状 | 范围 | 观察证据 | +|---|---|---| +| 每次构建都重编主 crate(41–87 秒,从不变 fresh) | Windows + macOS(Linux 上五条 staging 全为 no-op,不受影响) | `CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --no-default-features` 打印 `stale: changed "resources/codex/mac-native/darwin-arm64/NOTICE.md"`,且 `.fingerprint/**/run-build-script-build-script-build.json` 的 `RerunIfChanged.paths` 含 staging 目标路径 | +| `npm run agc` 反复 `Rebuilding application`,客户端起不来 | 仅 macOS | dev 日志一轮 28 次以上不收敛;原因是被重写的 `resources/codex/mac-native/**` 落在监听范围内且未被忽略 | +| `remove_dir_all` 抛 `DirectoryNotEmpty`,整次启动中断 | 并发重建时 | `清理 macOS Codex staging 失败` | + +### 3.4 三轮症状层修复都没有收口 + +| commit | 日期 | 修的是什么 | 结果 | +|---|---|---|---| +| `299877b19` | 2026-09-11 | 插件资源改成「内容变化才落盘」+ 引入 `.taurignore` | 只覆盖插件路径,且挡不住 cargo stale | +| `710d6dddf` | 2026-09-23(PR #487) | Windows 上「权限一致就不写元数据」 | 只减少一类写入,未解决整目录重建 | +| 本次未合入的 B 改动 | 2026-09-26 | 全部写入改幂等 + 按布局清残留 + 补 `.taurignore` | 实测可行(第二次 `cargo build` 0.25 秒 fresh),但规则靠人守:新增一条写 `resources/**` 的步骤漏改即回退(godot/cocos/plugin.json 正是三个漏点) | + +结论:**只要 build script 继续写这些受跟踪路径,就必须持续为每一处写入维护「无改动不写」,成本随写入点增长**;这是结构问题,不是疏漏问题。 + +## 4. 方案设计 + +### 4.1 原则 + +build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要「由本仓库生成、再随包分发」的内容,都由 `tauri dev|build` 之前的**准备步骤**生成,build script 只校验。 + +### 4.2 目标形态 + +```text +准备步骤(新,唯一写入方) build.rs(退化为校验者) + fetch/校验上游 → 生成到 staging 目录 只读 resources/** → 校验 manifest/hash/版本/平台 + → 原子替换到 resources/** → 不写任何随包资源 + → 写 manifest.json(schema 化) + ↑ ↑ + dev: start-tauri-dev 内、spawn tauri 之前 release: build-release/build-macos-ci 内 +``` + +### 4.3 准备步骤的合同(必须成立的行为) + +1. **调用时机**:`tauri dev` / `tauri build` 之前完成;任何入口都不得依赖 build script 兜底生成。 +2. **缓存与幂等**:以「上游 lockfile `resolved` + `integrity` + 布局版本 + 目标三元」为 key;命中且 manifest 校验通过的 staging 目录不重写任何文件(避免把「产物」变成「每次构建都变」的新源头)。 +3. **原子性**:写入 staging 临时目录后 rename 替换;不得出现半成品目录(杜绝并发下的 `DirectoryNotEmpty`)。 +4. **所有权**:只允许替换由本工具创建并带 manifest 的目录;遇到非本工具目录、符号链接、越界路径必须 fail closed(沿用 `stage-node-runtime.mjs` 与 `prepare-macos-codex.mjs` 既有判定)。 +5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 +6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 +7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 +8. **并发安全**:同一 staging 目录的并发调用必须串行化或幂等收敛(dev 与 IDE 各自触发时不能互相破坏)。 + +### 4.4 build.rs 退化后的职责 + +保留: + +1. 读取 `TARGET` 并写 `cargo:rustc-env=AGC_BUILD_TARGET`(运行时定位随包目录依赖它)。 +2. 校验 `resources/**` 与清单一致:平台目录存在、manifest schema/平台/版本、逐文件 sha256、必需组件齐全(缺一即 fail closed)。 +3. 声明**真实输入**:上游源文件、布局表、受版本控制资源(含 macOS 声明文件)的 `rerun-if-changed`;不得声明任何由本脚本或准备步骤生成的文件。 +4. `tauri_build::build()` 与既有 Prompt Bundle、能力与配置校验。 + +删除: + +1. 五条 staging 的写入逻辑、`prune_staging`、`STAGED_*` 幂等判断、针对大目录的整目录删除。 +2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 + +### 4.5 资源清单(谁生成、谁消费) + +| 资源 | 生成方式 | 运行时消费方 | dev 是否需要 | +|---|---|---|---| +| `resources/codex/**` | 纯复制(上游平台包 `vendor/`) | `codex_cli.rs`(内置 sidecar 优先,其次 npm 目录、PATH) | macOS dev 只接受真 `.app` 的 `Contents/Resources`,非 `.app` 场景走 npm/PATH 回退;Windows dev 从 exe 同级读取 | +| `resources/plugins/**` | 复制 + 三个编辑器分支的产物 | `plugin_host.rs`(`AGC_PLUGIN_WORKSPACE` → `resource_dir/plugins` → dev 回退仓库 `plugins/`)、`editor_adapters.rs` | dev 有仓库回退,但 cocos/unity/godot payload 仍以随包路径为准 | +| `resources/plugins/agc-unity-editor/**`、`agc-godot-editor/native/gdextension/**` | 外部工具链(Windows 专属) | `editor_adapters.rs` 候选链 | 仅 Windows | +| `resources/node-runtime/**` | 已有:`stage-node-runtime.mjs` | `environment_check.rs`(`agc-node-runtime.v1` 全量 sha256) | 发布与需要随包 Node 的 dev | +| `design-agent`、`vendor/*` 许可 | 受版本控制 | `design_tools.rs` 等 | 无需 staging | + +### 4.6 入口接线 + +| 入口 | 位置 | 现状 | 改造后 | +|---|---|---|---| +| AGC dev | `start-tauri-dev.mjs`(`runTauriDev` → 预检 → 前端 → `spawnCli`,准备点在前端就绪之后、`spawnCli` 之前) | 无准备步骤,依赖 build script | 在 `spawnCli` 之前调用准备步骤(命中缓存时秒退) | +| Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | +| macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | +| 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 保持「不打包就不强制 staging」的口径,但校验器要按「未打包」放行 | +| CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | + +### 4.7 与现有机制的关系 + +1. **已有先例**:`stage-node-runtime.mjs` 已实现 schema 常量、目标平台失败关闭、staging 目录 + rename 原子替换、拒绝覆盖非本工具目录;`prepare-macos-codex.mjs` 已实现 lockfile `integrity` 驱动的下载、缓存与原子替换。准备步骤应复用这两套范式而不是另起一套。 +2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 +3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 + +## 5. 兼容与迁移 + +1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 +2. **过渡期**:改造期间 `resources/**` 仍由 build script 生成(当前主线状态),准备步骤上线后先并存(生成结果与校验一致),再删除 build script 的写入分支——删除与新增不得跨里程碑混在一起。 +3. **本机残留**:改造后 `.taurignore` 的 staging 条目、`prune_staging` 及相关注释一并删除;`resources/**` 仍保持 gitignored。 +4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 + +## 6. 验收标准与证据 + +| 项 | 判据 | +|---|---| +| 构建新鲜度 | 源码不变时连续两次 `cargo build --no-default-features` 第二次为秒级 `Finished`;IDE 的 `cargo check --all-targets` 同样不重复构建 | +| 无自触发 | `cargo:rerun-if-changed` 输出与 fingerprint 记录里不出现任何 `src-tauri/resources/**` 路径 | +| dev 可用 | macOS/Windows `npm run agc` 在准备步骤后一次成功:`Rebuilding application` 为 0 次、`Running DevCommand` 为 1 次,客户端与 Runner 进程稳定存活 | +| 包内容 | `check-macos-bundle.mjs` 全绿;Windows 安装包内 `coding-agent`、`plugins`、`game-runtime/node` 与改造前逐项一致 | +| 失败关闭 | 上游缺失 / integrity 不匹配 / 清单缺组件 / 目标平台不支持 四类场景各自返回明确错误且不产出包 | +| 幂等 | 准备步骤连续执行两次,staging 目录内容与 mtime 不变(不改动受跟踪输入) | +| 门禁 | `check-config.mjs`、`check:encoding`、`check:doc-index`、`git diff --check`、AGC 相关 vitest 与 Rust 测试全绿 | + +未验证项必须在交付记录中标注(例如 Windows 真机行为、真实上游包下载在受限网络下的表现)。 + +## 7. 风险与回滚 + +| 风险 | 影响 | 措施 | +|---|---|---| +| 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | +| staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | +| 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | +| 并发调用(dev 与 IDE 同时触发) | staging 目录损坏 | 原子替换 + 所有权校验 + 有界重试 | +| 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | + +回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 + +## 8. 里程碑拆分(建议) + +| 里程碑 | 交付 | 停止条件 | +|---|---|---| +| M1 校验器化 | `build.rs` 增加只读校验路径、准备步骤脚本骨架(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿,且不改变现有构建行为 | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | + +里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 + +## 9. 未决问题 + +1. 准备步骤用 Rust bin(可复用 `codex_bundle.rs` 的布局表与校验)还是 Node 脚本(与 `stage-node-runtime.mjs` 同构)?倾向前者以复用布局表与 sha256 校验,避免第二份白名单。 +2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 +3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 From 756297d2df90d5f99e021463ea880de9d6a57176 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:42 +0800 Subject: [PATCH 07/60] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=20staging=20=E5=BD=92=E4=BD=8D?= =?UTF-8?q?=E9=87=8C=E7=A8=8B=E7=A2=91=E4=B8=8E=E9=A6=96=E6=AE=B5=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增三份里程碑规范:资源改由校验器读入、生成接入 dev 与发布入口、编辑器分支产物归位与症状层补丁清理 - 新增首个里程碑实施计划:修改边界、实现顺序、验证命令、风险与回滚点、待确认的实现形态 --- ...计划】AGC随包资源改由校验器读入-2026-09-26.md | 62 +++++++++++++++++++ ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 44 +++++++++++++ ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 44 +++++++++++++ ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 46 ++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..f609c1c72 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,62 @@ +# 【实施计划】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| --------- | --------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` | +| Status | ready(待里程碑规范评审通过后开工) | +| Owner | suzmii / Agent | + +## 修改边界 + +允许修改: + +- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。 +- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。 +- 新增随包资源准备工具及其测试(位置见「待确认决策」)。 +- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。 +- 文档:主规范未决问题收口、开发运维文档对应段落。 + +明确不修改: + +- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。 +- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。 +- 发布脚本流程、版本号机制、签名与上传。 +- dev 启动器与发布入口的接线(下一里程碑)。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。 + +## 实现顺序 + +1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。 +2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。 +3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。 +4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。 +5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。 + +## 验证命令 + +1. 布局与白名单唯一性:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features codex_bundle` +2. 校验路径用例:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features <新增校验用例过滤名>` +3. 幂等:连续两次运行准备工具,比对目标目录内容与时间戳快照(内容与 mtime 均不变) +4. 失败关闭:对上游缺失、摘要篡改、非本工具目录、非目标平台四种场景各跑一次,记录错误输出 +5. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线) +6. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试 + +## 风险与回滚点 + +| 风险 | 影响 | 处理 | +| --- | --- | --- | +| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 | +| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 | +| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 | +| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 | + +回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 + +## 待确认决策 + +准备工具的实现形态(主规范未决问题 1)建议为**混合**: + +- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具调用或由校验路径承担。 + +理由:避免出现第二份组件白名单;同时不必重写 registry 下载、`integrity` 与 tar 安全校验。若评审倾向单一语言实现,需接受「另写一份白名单」或「把 Node 侧下载能力用 Rust 重写」二者之一。 diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md new file mode 100644 index 000000000..8fcc64b1e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -0,0 +1,44 @@ +# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 + +## 范围 + +- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。 +- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。 +- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。 +- 与发布打包、包内资源门禁、运行时解析的一致性核对。 + +## 不在范围内 + +- 编辑器分支本身的接入协议、宿主能力与运行时行为。 +- 外部工具链版本管理与安装流程(沿用现状)。 +- 随包组件与插件工作区的生成形态(上一里程碑已完成)。 + +## 依赖与前置条件 + +- 前两个里程碑验收通过。 +- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。 + +## 验收标准 + +- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。 +- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 +- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 +- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 + +## 证据要求 + +- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 +- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。 +- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..57c6d8e2a --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,44 @@ +# 【里程碑】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| ----------- | ----------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 + +## 范围 + +- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。 +- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。 +- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。 + +## 不在范围内 + +- 接入 dev 与发布入口(下一里程碑)。 +- 移除构建脚本里的资源写入分支。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。 +- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。 +- Linux 产物支持。 + +## 依赖与前置条件 + +- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。 +- 现有随包资源与清单已由当前实现产出,可用于校验回归。 + +## 验收标准 + +- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。 +- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。 +- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。 +- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。 + +## 证据要求 + +- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。 +- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。 +- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md new file mode 100644 index 000000000..1f70aa288 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -0,0 +1,46 @@ +# 【里程碑】AGC 随包资源生成接入 dev 与发布入口 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。 + +## 范围 + +- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。 +- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。 +- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。 +- 不打包场景(仅校验、不产包)的放行口径。 + +## 不在范围内 + +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。 +- 打包配置里的资源映射、包内资源门禁与安装包形态。 +- 运行时资源解析与完整性校验语义。 +- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。 + +## 依赖与前置条件 + +- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 +- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 + +## 验收标准 + +- [ ] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。 +- [ ] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。 +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。 +- [ ] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。 +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。 + +## 证据要求 + +- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 +- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 +- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 From 21c3bd7a2b39e05bc8d786be42ce9767b24a831e Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:35:57 +0800 Subject: [PATCH 08/60] =?UTF-8?q?=E5=BC=95=E5=85=A5=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E7=9A=84=E5=8D=95=E4=B8=80=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E4=B8=8E=E7=94=9F=E6=88=90=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 build_support/package-layout.json:Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛的唯一人工声明 - 新增 scripts/check-package-layout.mjs:由声明生成 build_support/package-layout.generated.rs,并校验目标唯一、可执行组件属于白名单、每个目标恰有一条第三方声明来源、codex.version 与应用锁定的 @openai/codex 一致 - 新增 build_support/package_layout.rs:声明类型、插件与平台门槛判定、逐块 sha256、随包 Codex 目录只读校验及其单测 - codex_bundle.rs 的布局与版本常量改由声明提供,公开接口与取值不变;src/main.rs 在测试期引入该模块以复用既有单测 - package.json 新增 bundled-resources:check/sync 并接进 typecheck 链,根 package.json 提供 agc:* 别名 --- apps/ai-game-creator-shell/package.json | 6 +- .../scripts/check-package-layout.mjs | 477 ++++++++++++ .../src-tauri/build_support/codex_bundle.rs | 68 +- .../build_support/package-layout.generated.rs | 115 +++ .../build_support/package-layout.json | 110 +++ .../src-tauri/build_support/package_layout.rs | 684 ++++++++++++++++++ .../src-tauri/src/main.rs | 5 + package.json | 4 + 8 files changed, 1426 insertions(+), 43 deletions(-) create mode 100755 apps/ai-game-creator-shell/scripts/check-package-layout.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c9c7a2d3a..894ae2ffc 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -13,6 +13,10 @@ "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", + "bundled-resources:check": "node scripts/check-package-layout.mjs", + "bundled-resources:sync": "node scripts/check-package-layout.mjs --write", + "bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs", + "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "config": "node scripts/game-creator-config-wizard.mjs", @@ -24,7 +28,7 @@ "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs" + "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@cubone/react-file-manager": "^1.35.0", diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs new file mode 100755 index 000000000..d3a5e1237 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -0,0 +1,477 @@ +#!/usr/bin/env node +// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成 +// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。 +// +// 用法: +// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1) +// node scripts/check-package-layout.mjs --write 重新生成 +// +// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; +// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 + +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const SRC_TAURI = path.join(APP_ROOT, 'src-tauri'); +const DECLARATION_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.json', +); +const GENERATED_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.generated.rs', +); + +const EXPECTED_SCHEMA = 'agc-package-layout.v1'; + +class DeclarationError extends Error {} + +function fail(message) { + throw new DeclarationError(message); +} + +function expectObject(value, at) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${at} 必须是对象`); + } + return value; +} + +function expectArray(value, at) { + if (!Array.isArray(value)) { + fail(`${at} 必须是数组`); + } + return value; +} + +function expectString(value, at) { + if (typeof value !== 'string' || value.length === 0) { + fail(`${at} 必须是非空字符串`); + } + return value; +} + +function expectBoolean(value, at) { + if (typeof value !== 'boolean') { + fail(`${at} 必须是布尔值`); + } + return value; +} + +function expectStringArray(value, at) { + return expectArray(value, at).map((item, index) => + expectString(item, `${at}[${index}]`), + ); +} + +function optionalStringArray(source, key, at) { + if (source[key] === undefined) { + return []; + } + return expectStringArray(source[key], `${at}.${key}`); +} + +function expectInteger(value, at) { + if (!Number.isInteger(value) || value < 0) { + fail(`${at} 必须是非负整数`); + } + return value; +} + +// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 +function rustString(value) { + return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); +} + +function rustStrings(values) { + return `&[${values.map(rustString).join(', ')}]`; +} + +function indent(level) { + return ' '.repeat(level); +} + +function renderStruct(name, fields, level = 0) { + const body = fields + .map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`) + .join('\n'); + return `${name} {\n${body}\n${indent(level)}}`; +} + +function parseDeclaration(raw) { + const root = expectObject(JSON.parse(raw), 'root'); + if (root.schema !== EXPECTED_SCHEMA) { + fail( + `不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`, + ); + } + const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion'); + + const codex = expectObject(root.codex, 'codex'); + const targets = expectArray(codex.targets, 'codex.targets').map( + (entry, index) => { + const at = `codex.targets[${index}]`; + const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + const executable = expectString(parsed.executable, `${at}.executable`); + if (!files.includes(executable)) { + fail(`${at}.executable 必须属于组件白名单:${executable}`); + } + return { + target: expectString(parsed.target, `${at}.target`), + platform: expectString(parsed.platform, `${at}.platform`), + directory: expectString(parsed.directory, `${at}.directory`), + executable, + files, + }; + }, + ); + const seenTargets = new Set(); + for (const entry of targets) { + if (seenTargets.has(entry.target)) { + fail(`codex.targets 重复声明目标 ${entry.target}`); + } + seenTargets.add(entry.target); + } + + const noticeSources = expectArray( + codex.noticeSources, + 'codex.noticeSources', + ).map((entry, index) => { + const at = `codex.noticeSources[${index}]`; + const parsed = expectObject(entry, at); + return { + targets: expectStringArray(parsed.targets, `${at}.targets`), + source: expectString(parsed.source, `${at}.source`), + preserve: expectBoolean(parsed.preserve, `${at}.preserve`), + }; + }); + for (const entry of targets) { + const covered = noticeSources.filter((notice) => + notice.targets.includes(entry.target), + ); + if (covered.length !== 1) { + fail( + `目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`, + ); + } + } + + const universalGroups = expectArray( + codex.universalGroups, + 'codex.universalGroups', + ).map((entry, index) => { + const at = `codex.universalGroups[${index}]`; + const parsed = expectObject(entry, at); + const groupTargets = expectStringArray(parsed.targets, `${at}.targets`); + for (const target of groupTargets) { + if (!seenTargets.has(target)) { + fail(`${at}.targets 含未声明目标 ${target}`); + } + } + return { + name: expectString(parsed.name, `${at}.name`), + directory: expectString(parsed.directory, `${at}.directory`), + targets: groupTargets, + }; + }); + + const plugins = expectObject(root.plugins, 'plugins'); + const subdirectories = expectArray( + plugins.subdirectories, + 'plugins.subdirectories', + ).map((entry, index) => { + const at = `plugins.subdirectories[${index}]`; + const parsed = expectObject(entry, at); + return { + path: expectString(parsed.path, `${at}.path`), + targetContains: optionalStringArray(parsed, 'targetContains', at), + targets: optionalStringArray(parsed, 'targets', at), + features: optionalStringArray(parsed, 'features', at), + }; + }); + const libraryStaging = expectArray( + plugins.libraryStaging, + 'plugins.libraryStaging', + ).map((entry, index) => { + const at = `plugins.libraryStaging[${index}]`; + const parsed = expectObject(entry, at); + return { + plugin: expectString(parsed.plugin, `${at}.plugin`), + sourceSubdirectory: expectString( + parsed.sourceSubdirectory, + `${at}.sourceSubdirectory`, + ), + targets: expectStringArray(parsed.targets, `${at}.targets`), + features: expectStringArray(parsed.features, `${at}.features`), + layout: expectString(parsed.layout, `${at}.layout`), + }; + }); + + return { + layoutVersion, + codex: { + version: expectString(codex.version, 'codex.version'), + cliVersionPrefix: expectString( + codex.cliVersionPrefix, + 'codex.cliVersionPrefix', + ), + manifestSchema: expectString( + codex.manifestSchema, + 'codex.manifestSchema', + ), + resourceDirectory: expectString( + codex.resourceDirectory, + 'codex.resourceDirectory', + ), + manifestFileName: expectString( + codex.manifestFileName, + 'codex.manifestFileName', + ), + packageMetadataFileName: expectString( + codex.packageMetadataFileName, + 'codex.packageMetadataFileName', + ), + noticeFileName: expectString( + codex.noticeFileName, + 'codex.noticeFileName', + ), + sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'), + sourceRelativePaths: expectStringArray( + codex.sourceRelativePaths, + 'codex.sourceRelativePaths', + ), + noticeSources, + universalGroups, + targets, + }, + plugins: { + sourceDirectory: expectString( + plugins.sourceDirectory, + 'plugins.sourceDirectory', + ), + destinationDirectory: expectString( + plugins.destinationDirectory, + 'plugins.destinationDirectory', + ), + manifestFileName: expectString( + plugins.manifestFileName, + 'plugins.manifestFileName', + ), + targetContainsAny: expectStringArray( + plugins.targetContainsAny, + 'plugins.targetContainsAny', + ), + subdirectories, + libraryStaging, + skipDirectoryNames: expectStringArray( + plugins.skipDirectoryNames, + 'plugins.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + plugins.skipDirectoryNamePrefixes, + 'plugins.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + plugins.skipFileNamePrefixes, + 'plugins.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + plugins.skipFileNameFragments, + 'plugins.skipFileNameFragments', + ), + }, + }; +} + +function renderCodexTarget(entry) { + return renderStruct( + 'CodexTarget', + [ + ['target', rustString(entry.target)], + ['platform', rustString(entry.platform)], + ['directory', rustString(entry.directory)], + ['executable', rustString(entry.executable)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderNoticeSource(entry) { + return renderStruct( + 'NoticeSource', + [ + ['targets', rustStrings(entry.targets)], + ['source', rustString(entry.source)], + ['preserve', entry.preserve ? 'true' : 'false'], + ], + 1, + ); +} + +function renderUniversalGroup(entry) { + return renderStruct( + 'UniversalGroup', + [ + ['name', rustString(entry.name)], + ['directory', rustString(entry.directory)], + ['targets', rustStrings(entry.targets)], + ], + 1, + ); +} + +function renderSubdirectory(entry) { + return renderStruct( + 'Subdirectory', + [ + ['path', rustString(entry.path)], + ['target_contains', rustStrings(entry.targetContains)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ], + 1, + ); +} + +function renderLibraryStaging(entry) { + return renderStruct( + 'LibraryStaging', + [ + ['plugin', rustString(entry.plugin)], + ['source_subdirectory', rustString(entry.sourceSubdirectory)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ['layout', rustString(entry.layout)], + ], + 1, + ); +} + +function renderGenerated(declaration) { + const codex = declaration.codex; + const plugins = declaration.plugins; + const codexStruct = renderStruct('Codex', [ + ['resource_directory', rustString(codex.resourceDirectory)], + ['manifest_file_name', rustString(codex.manifestFileName)], + ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], + ['notice_file_name', rustString(codex.noticeFileName)], + ['source_roots', rustStrings(codex.sourceRoots)], + ['source_relative_paths', rustStrings(codex.sourceRelativePaths)], + [ + 'notice_sources', + `&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`, + ], + [ + 'universal_groups', + `&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`, + ], + ['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`], + ]); + const pluginsStruct = renderStruct('Plugins', [ + ['source_directory', rustString(plugins.sourceDirectory)], + ['destination_directory', rustString(plugins.destinationDirectory)], + ['manifest_file_name', rustString(plugins.manifestFileName)], + ['target_contains_any', rustStrings(plugins.targetContainsAny)], + [ + 'subdirectories', + `&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`, + ], + [ + 'library_staging', + `&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`, + ], + ['skip_directory_names', rustStrings(plugins.skipDirectoryNames)], + [ + 'skip_directory_name_prefixes', + rustStrings(plugins.skipDirectoryNamePrefixes), + ], + ['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)], + ['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)], + ]); + + return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; +pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion}; + +pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; +pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; +pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; + +pub const CODEX: Codex = ${codexStruct}; + +pub const PLUGINS: Plugins = ${pluginsStruct}; +`; +} + +function appLockedCodexVersion() { + const appPackage = expectObject( + JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')), + 'apps/ai-game-creator-shell/package.json', + ); + const declared = + appPackage.dependencies?.['@openai/codex'] ?? + appPackage.devDependencies?.['@openai/codex']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + '应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version', + ); + } + return declared.replace(/^[\^~]/, ''); +} + +function main() { + const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + const lockedVersion = appLockedCodexVersion(); + if (lockedVersion !== declaration.codex.version) { + fail( + `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + const rendered = renderGenerated(declaration); + const write = process.argv.includes('--write'); + if (write) { + writeFileSync(GENERATED_PATH, rendered); + console.log( + `随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`, + ); + return; + } + const current = readFileSync(GENERATED_PATH, 'utf8'); + if (current !== rendered) { + console.error( + [ + `随包资源声明与 Rust 常量不一致:`, + ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, + ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, + '请运行:npm run agc:package-layout:sync', + ].join('\n'), + ); + process.exit(1); + } + console.log( + `随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`, + ); +} + +try { + main(); +} catch (error) { + if (error instanceof DeclarationError) { + console.error(`随包资源声明无效:${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index e2064d28d..d802866df 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -1,8 +1,18 @@ //! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 +//! +//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json` +//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量, +//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。 +//! 本模块只读声明,不写任何随包资源。 -pub const VERSION: &str = "0.155.1"; -pub const CLI_VERSION: &str = "codex-cli 0.155.1"; -pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; +// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +pub(crate) mod package_layout; + +pub const VERSION: &str = package_layout::CODEX_VERSION; +pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION; +pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA; #[derive(Clone, Copy, Debug)] pub struct Layout { @@ -12,46 +22,13 @@ pub struct Layout { pub files: &'static [&'static str], } -const WINDOWS_FILES: &[&str] = &[ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; -const MAC_FILES: &[&str] = &[ - "bin/codex", - "bin/codex-code-mode-host", - "codex-path/rg", - "codex-resources/zsh/bin/zsh", - "codex-package.json", -]; - pub fn for_target(target: &str) -> Option { - match target { - "x86_64-pc-windows-msvc" => Some(Layout { - platform: "win32-x64", - directory: "win-x64", - executable: "bin/codex.exe", - files: WINDOWS_FILES, - }), - "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { - platform: if target.starts_with("aarch64") { - "darwin-arm64" - } else { - "darwin-x64" - }, - directory: if target.starts_with("aarch64") { - "mac-native/darwin-arm64" - } else { - "mac-native/darwin-x64" - }, - executable: "bin/codex", - files: MAC_FILES, - }), - _ => None, - } + package_layout::codex_target(target).map(|declared| Layout { + platform: declared.platform, + directory: declared.directory, + executable: declared.executable, + files: declared.files, + }) } #[cfg(test)] @@ -80,4 +57,11 @@ mod tests { assert!(for_target("aarch64-pc-windows-msvc").is_none()); assert!(for_target("x86_64-unknown-linux-gnu").is_none()); } + + #[test] + fn constants_come_from_the_shared_declaration() { + assert_eq!(VERSION, "0.155.1"); + assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}")); + assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs new file mode 100644 index 000000000..ebfe90b06 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -0,0 +1,115 @@ +// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; +pub const LAYOUT_VERSION: u64 = 1; + +pub const CODEX_VERSION: &str = "0.155.1"; +pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; +pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +pub const CODEX: Codex = Codex { + resource_directory: "resources/codex", + manifest_file_name: "manifest.json", + package_metadata_file_name: "codex-package.json", + notice_file_name: "NOTICE.md", + source_roots: &["app", "repo"], + source_relative_paths: &["node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"], + notice_sources: &[ +NoticeSource { + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + preserve: false, + }, +NoticeSource { + targets: &["x86_64-pc-windows-msvc"], + source: "resources/codex/win-x64/NOTICE.md", + preserve: true, + } +], + universal_groups: &[ +UniversalGroup { + name: "mac-native", + directory: "mac-native", + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + } +], + targets: &[ +CodexTarget { + target: "x86_64-pc-windows-msvc", + platform: "win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"], + }, +CodexTarget { + target: "aarch64-apple-darwin", + platform: "darwin-arm64", + directory: "mac-native/darwin-arm64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + }, +CodexTarget { + target: "x86_64-apple-darwin", + platform: "darwin-x64", + directory: "mac-native/darwin-x64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + } +], +}; + +pub const PLUGINS: Plugins = Plugins { + source_directory: "plugins", + destination_directory: "resources/plugins", + manifest_file_name: "plugin.json", + target_contains_any: &["windows", "apple-darwin"], + subdirectories: &[ +Subdirectory { + path: "src", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "panels", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "skills", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "native/payload", + target_contains: &["windows"], + targets: &[], + features: &[], + }, +Subdirectory { + path: "dotnet/publish/win-x64", + target_contains: &[], + targets: &["x86_64-pc-windows-msvc"], + features: &["unity-editor-execute"], + } +], + library_staging: &[ +LibraryStaging { + plugin: "agc-godot-editor", + source_subdirectory: "native/gdextension", + targets: &["x86_64-pc-windows-msvc"], + features: &["godot-editor-execute"], + layout: "godot-bundle", + } +], + skip_directory_names: &["target", "node_modules"], + skip_directory_name_prefixes: &["."], + skip_file_name_prefixes: &["."], + skip_file_name_fragments: &[".test."], +}; diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json new file mode 100644 index 000000000..21ac80bbe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -0,0 +1,110 @@ +{ + "schema": "agc-package-layout.v1", + "layoutVersion": 1, + "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。", + "codex": { + "version": "0.155.1", + "cliVersionPrefix": "codex-cli ", + "manifestSchema": "genarrative-codex-sidecar.v2", + "resourceDirectory": "resources/codex", + "manifestFileName": "manifest.json", + "packageMetadataFileName": "codex-package.json", + "noticeFileName": "NOTICE.md", + "sourceRoots": ["app", "repo"], + "sourceRelativePaths": [ + "node_modules/@openai/codex-/vendor/", + "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" + ], + "noticeSources": [ + { + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + "preserve": false + }, + { + "targets": ["x86_64-pc-windows-msvc"], + "source": "resources/codex/win-x64/NOTICE.md", + "preserve": true + } + ], + "universalGroups": [ + { + "name": "mac-native", + "directory": "mac-native", + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + } + ], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "platform": "win32-x64", + "directory": "win-x64", + "executable": "bin/codex.exe", + "files": [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json" + ] + }, + { + "target": "aarch64-apple-darwin", + "platform": "darwin-arm64", + "directory": "mac-native/darwin-arm64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + }, + { + "target": "x86_64-apple-darwin", + "platform": "darwin-x64", + "directory": "mac-native/darwin-x64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + } + ] + }, + "plugins": { + "sourceDirectory": "plugins", + "destinationDirectory": "resources/plugins", + "manifestFileName": "plugin.json", + "targetContainsAny": ["windows", "apple-darwin"], + "subdirectories": [ + { "path": "src" }, + { "path": "panels" }, + { "path": "skills" }, + { "path": "native/payload", "targetContains": ["windows"] }, + { + "path": "dotnet/publish/win-x64", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["unity-editor-execute"] + } + ], + "libraryStaging": [ + { + "plugin": "agc-godot-editor", + "sourceSubdirectory": "native/gdextension", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["godot-editor-execute"], + "layout": "godot-bundle" + } + ], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."] + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs new file mode 100644 index 000000000..28ae352b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -0,0 +1,684 @@ +//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。 +//! +//! 人工声明只有一处:`build_support/package-layout.json`。Rust 侧使用由 +//! `scripts/check-package-layout.mjs` 从该声明生成的编译期常量 +//! (`package-layout.generated.rs`,门禁校验两者一致),Node 侧准备步骤直接读声明本身。 +//! 本模块**只读**:既不解析 JSON,也不写任何随包资源。 + +use std::collections::BTreeSet; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// 目标三元 → 平台包与组件白名单。 +#[derive(Debug)] +pub struct CodexTarget { + pub target: &'static str, + pub platform: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +/// 一次构建共用的整目录(例如 macOS 的 `mac-native` 双架构)。 +#[derive(Debug)] +pub struct UniversalGroup { + pub name: &'static str, + pub directory: &'static str, + pub targets: &'static [&'static str], +} + +/// 第三方声明的来源;`preserve` 表示该文件受版本控制、只允许原地保留。 +#[derive(Debug)] +pub struct NoticeSource { + pub targets: &'static [&'static str], + pub source: &'static str, + pub preserve: bool, +} + +/// 插件工作区的随包子目录及其生效条件。 +#[derive(Debug)] +pub struct Subdirectory { + pub path: &'static str, + pub target_contains: &'static [&'static str], + pub targets: &'static [&'static str], + pub features: &'static [&'static str], +} + +/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +#[derive(Debug)] +pub struct LibraryStaging { + pub plugin: &'static str, + pub source_subdirectory: &'static str, + pub targets: &'static [&'static str], + pub features: &'static [&'static str], + pub layout: &'static str, +} + +#[derive(Debug)] +pub struct Codex { + pub resource_directory: &'static str, + pub manifest_file_name: &'static str, + pub package_metadata_file_name: &'static str, + pub notice_file_name: &'static str, + pub source_roots: &'static [&'static str], + pub source_relative_paths: &'static [&'static str], + pub notice_sources: &'static [NoticeSource], + pub universal_groups: &'static [UniversalGroup], + pub targets: &'static [CodexTarget], +} + +#[derive(Debug)] +pub struct Plugins { + pub source_directory: &'static str, + pub destination_directory: &'static str, + pub manifest_file_name: &'static str, + pub target_contains_any: &'static [&'static str], + pub subdirectories: &'static [Subdirectory], + pub library_staging: &'static [LibraryStaging], + pub skip_directory_names: &'static [&'static str], + pub skip_directory_name_prefixes: &'static [&'static str], + pub skip_file_name_prefixes: &'static [&'static str], + pub skip_file_name_fragments: &'static [&'static str], +} + +include!("package-layout.generated.rs"); + +const PLATFORM_PLACEHOLDER: &str = ""; +const TARGET_PLACEHOLDER: &str = ""; + +pub fn codex() -> &'static Codex { + &CODEX +} + +pub fn plugins() -> &'static Plugins { + &PLUGINS +} + +/// 声明里的目标布局;未声明的目标返回 `None`。 +pub fn codex_target(target: &str) -> Option<&'static CodexTarget> { + CODEX.targets.iter().find(|entry| entry.target == target) +} + +/// 目标所属的整目录分组(macOS 双架构共用 `mac-native`)。 +pub fn codex_universal_group(target: &str) -> Option<&'static UniversalGroup> { + CODEX + .universal_groups + .iter() + .find(|group| group.targets.contains(&target)) +} + +/// 目标对应的第三方声明来源。 +pub fn codex_notice_source(target: &str) -> Option<&'static NoticeSource> { + CODEX + .notice_sources + .iter() + .find(|entry| entry.targets.contains(&target)) +} + +/// 单次构建需要校验的平台目录:属于整目录分组时是该组全部三元,否则是自身;不支持的目标为空。 +pub fn staged_targets(target: &str) -> Vec<&'static str> { + if let Some(group) = codex_universal_group(target) { + return group.targets.to_vec(); + } + codex_target(target) + .map(|declared| vec![declared.target]) + .unwrap_or_default() +} + +/// 上游平台包的候选路径,顺序与声明一致(先 app 目录后仓库根,各自按声明顺序)。 +pub fn codex_source_candidates( + app_root: &Path, + repo_root: &Path, + target: &str, +) -> Result, String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let mut candidates = Vec::new(); + for root in CODEX.source_roots { + let base = match *root { + "app" => app_root, + "repo" => repo_root, + other => return Err(format!("声明中的 sourceRoots 取值无效:{other}")), + }; + for relative in CODEX.source_relative_paths { + let expanded = relative + .replace(PLATFORM_PLACEHOLDER, layout.platform) + .replace(TARGET_PLACEHOLDER, target); + candidates.push(base.join(expanded)); + } + } + Ok(candidates) +} + +/// 插件随包 staging 是否适用于该目标(与声明里的平台门槛一致)。 +pub fn plugin_staging_applies(target: &str) -> bool { + PLUGINS + .target_contains_any + .iter() + .any(|needle| target.contains(needle)) +} + +/// 子目录在当前目标与已启用 feature 下是否随包。 +pub fn subdirectory_enabled( + subdirectory: &Subdirectory, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (subdirectory.target_contains.is_empty() + || subdirectory + .target_contains + .iter() + .any(|needle| target.contains(needle))) + && (subdirectory.targets.is_empty() || subdirectory.targets.contains(&target)) + && subdirectory + .features + .iter() + .all(|name| feature_enabled(name)) +} + +/// 随包库归位在当前目标与已启用 feature 下是否生效。 +pub fn library_staging_enabled( + staging: &LibraryStaging, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (staging.targets.is_empty() || staging.targets.contains(&target)) + && staging.features.iter().all(|name| feature_enabled(name)) +} + +/// Cargo build script 上下文里该 feature 是否启用(`CARGO_FEATURE_`)。 +pub fn cargo_feature_enabled(name: &str) -> bool { + let variable = format!("CARGO_FEATURE_{}", name.to_uppercase().replace('-', "_")); + std::env::var_os(variable).is_some() +} + +/// 目录是否被跳过(构建产物目录与隐藏目录)。 +pub fn skip_directory(name: &str) -> bool { + PLUGINS.skip_directory_names.contains(&name) + || PLUGINS + .skip_directory_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// 文件是否被跳过(测试文件与隐藏文件)。 +pub fn skip_file_name(name: &str) -> bool { + PLUGINS + .skip_file_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + || PLUGINS + .skip_file_name_fragments + .iter() + .any(|fragment| name.contains(fragment)) +} + +/// 逐块 sha256;构建期校验与 staging 比对共用同一实现。 +pub fn sha256_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// 声明里的相对路径一律使用 `/`,落盘时转换为平台分隔符。 +pub fn declared_relative_path(relative: &str) -> PathBuf { + relative.split('/').collect() +} + +/// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 +/// +/// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 +/// 每个组件存在且摘要一致、第三方声明存在、可执行组件具备可执行位、目录内不存在白名单外的文件。 +pub fn validate_staged_codex_bundle(target_dir: &Path, target: &str) -> Result<(), String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let metadata = std::fs::symlink_metadata(target_dir) + .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?; + if !metadata.is_dir() { + return Err(format!("随包目录不是目录:{}", target_dir.display())); + } + if metadata.file_type().is_symlink() { + return Err(format!("随包目录不允许符号链接:{}", target_dir.display())); + } + + let manifest_path = target_dir.join(CODEX.manifest_file_name); + let manifest_raw = std::fs::read_to_string(&manifest_path) + .map_err(|error| format!("随包清单不可读 {}:{error}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_raw) + .map_err(|error| format!("随包清单不是合法 JSON {}:{error}", manifest_path.display()))?; + let schema = manifest["schemaVersion"].as_str().unwrap_or_default(); + if schema != CODEX_MANIFEST_SCHEMA { + return Err(format!( + "随包清单 schema 不受支持:{schema}(期望 {CODEX_MANIFEST_SCHEMA})" + )); + } + let platform = manifest["platform"].as_str().unwrap_or_default(); + if platform != layout.platform { + return Err(format!( + "随包清单平台与目标不一致:{platform}(目标 {target} 期望 {})", + layout.platform + )); + } + let version = manifest["version"].as_str().unwrap_or_default(); + if version != CODEX_CLI_VERSION { + return Err(format!( + "随包清单版本与声明不一致:{version}(期望 {CODEX_CLI_VERSION})" + )); + } + let hashes = manifest["files"] + .as_object() + .ok_or_else(|| format!("随包清单缺少 files 映射:{}", manifest_path.display()))?; + let declared = layout + .files + .iter() + .map(|relative| (*relative).to_string()) + .collect::>(); + let listed = hashes.keys().cloned().collect::>(); + if declared != listed { + let missing = declared.difference(&listed).cloned().collect::>(); + let unexpected = listed.difference(&declared).cloned().collect::>(); + return Err(format!( + "随包清单文件集合与组件白名单不一致(缺少 {missing:?},多出 {unexpected:?})" + )); + } + + for relative in layout.files { + let path = target_dir.join(declared_relative_path(relative)); + let file_metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("随包组件缺失 {relative}:{error}"))?; + if file_metadata.file_type().is_symlink() { + return Err(format!("随包组件不允许符号链接:{relative}")); + } + if !file_metadata.is_file() { + return Err(format!("随包组件不是普通文件:{relative}")); + } + if file_metadata.len() == 0 { + return Err(format!("随包组件为空:{relative}")); + } + let expected = hashes + .get(*relative) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("随包清单摘要格式无效:{relative} = {expected}")); + } + let actual = + sha256_file(&path).map_err(|error| format!("读取随包组件失败 {relative}:{error}"))?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!( + "随包组件摘要与清单不一致:{relative}(清单 {expected},实际 {actual})" + )); + } + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + if file_metadata.permissions().mode() & 0o111 == 0 { + return Err(format!("随包可执行组件缺少可执行位:{relative}")); + } + } + } + + let notice = target_dir.join(CODEX.notice_file_name); + if !notice.is_file() { + return Err(format!("随包第三方声明缺失:{}", notice.display())); + } + + let mut allowed = declared.clone(); + allowed.insert(CODEX.notice_file_name.to_string()); + allowed.insert(CODEX.manifest_file_name.to_string()); + let actual = collect_tree_files(target_dir)?; + let unexpected = actual.difference(&allowed).cloned().collect::>(); + if !unexpected.is_empty() { + return Err(format!( + "随包目录存在白名单外的文件:{unexpected:?}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 递归收集目录下的普通文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_tree_files(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包目录不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + stack.push((entry.path(), relative)); + } else { + files.insert(relative); + } + } + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn declared_target() -> &'static CodexTarget { + codex_target("aarch64-apple-darwin").expect("mac layout") + } + + /// 在临时目录里生成一份合规的 Codex 随包目录。 + fn write_bundle(root: &Path, target: &str) -> serde_json::Value { + let layout = codex_target(target).expect("layout"); + let mut hashes = serde_json::Map::new(); + for relative in layout.files { + let path = root.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, format!("component {relative}")).expect("write component"); + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); + } + hashes.insert( + relative.to_string(), + serde_json::Value::String(sha256_file(&path).expect("hash")), + ); + } + fs::write(root.join(CODEX.notice_file_name), "notice").expect("write notice"); + let manifest = serde_json::json!({ + "schemaVersion": CODEX_MANIFEST_SCHEMA, + "platform": layout.platform, + "version": CODEX_CLI_VERSION, + "files": hashes, + }); + fs::write( + root.join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + manifest + } + + #[test] + fn valid_bundle_passes() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin").expect("valid bundle"); + } + + #[test] + fn rejects_manifest_schema_platform_and_version_drift() { + for (key, value) in [ + ("schemaVersion", serde_json::json!("other-sidecar.v9")), + ("platform", serde_json::json!("darwin-x64")), + ("version", serde_json::json!("codex-cli 0.0.0")), + ] { + let temp = tempfile::tempdir().expect("tempdir"); + let mut manifest = write_bundle(temp.path(), "aarch64-apple-darwin"); + manifest[key] = value; + fs::write( + temp.path().join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject"); + assert!(error.contains("清单"), "{key}: {error}"); + } + } + + #[test] + fn rejects_missing_component_and_digest_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = declared_target().executable; + fs::remove_file(temp.path().join(declared_relative_path(executable))).expect("remove"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing component"); + assert!(error.contains(executable), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let path = temp.path().join("codex-package.json"); + fs::write(&path, "tampered").expect("tamper"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject digest drift"); + assert!(error.contains("摘要"), "{error}"); + } + + #[test] + fn rejects_undeclared_file_and_missing_notice() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::write(temp.path().join("stray.bin"), "stray").expect("write stray"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject stray file"); + assert!(error.contains("白名单外"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::remove_file(temp.path().join(CODEX.notice_file_name)).expect("remove notice"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing notice"); + assert!(error.contains("第三方声明"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_component_and_missing_executable_bit() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::remove_file(&executable).expect("remove"); + symlink("codex-code-mode-host", &executable).expect("symlink"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).expect("chmod"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing executable bit"); + assert!(error.contains("可执行位"), "{error}"); + } + + #[test] + fn unsupported_target_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate_staged_codex_bundle(temp.path(), "x86_64-unknown-linux-gnu") + .expect_err("must reject unsupported target"); + assert!(error.contains("声明不含目标"), "{error}"); + } + + #[test] + fn declaration_pins_codex_layout() { + assert_eq!(DECLARATION_SCHEMA, "agc-package-layout.v1"); + assert_eq!(LAYOUT_VERSION, 1); + assert_eq!(CODEX_VERSION, "0.155.1"); + assert_eq!(CODEX_CLI_VERSION, "codex-cli 0.155.1"); + assert_eq!(CODEX.targets.len(), 3); + let windows = codex_target("x86_64-pc-windows-msvc").expect("windows layout"); + assert_eq!(windows.platform, "win32-x64"); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.executable, "bin/codex.exe"); + assert_eq!(windows.files.len(), 6); + assert_eq!(declared_target().files.len(), 5); + assert!(declared_target() + .files + .contains(&"codex-resources/zsh/bin/zsh")); + assert!(codex_target("universal-apple-darwin").is_none()); + assert!(codex_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn universal_group_covers_both_darwin_targets() { + let group = codex_universal_group("aarch64-apple-darwin").expect("darwin group"); + assert_eq!(group.directory, "mac-native"); + assert_eq!( + group.targets, + ["aarch64-apple-darwin", "x86_64-apple-darwin"] + ); + assert_eq!(staged_targets("aarch64-apple-darwin").len(), 2); + assert_eq!( + staged_targets("x86_64-pc-windows-msvc"), + ["x86_64-pc-windows-msvc"] + ); + assert!(staged_targets("x86_64-unknown-linux-gnu").is_empty()); + } + + #[test] + fn notice_sources_cover_every_declared_target() { + for target in ["x86_64-pc-windows-msvc", "aarch64-apple-darwin"] { + assert!( + codex_notice_source(target).is_some(), + "缺少声明来源:{target}" + ); + } + assert!( + codex_notice_source("x86_64-pc-windows-msvc") + .expect("windows notice") + .preserve + ); + assert!( + !codex_notice_source("x86_64-apple-darwin") + .expect("mac notice") + .preserve + ); + } + + #[test] + fn source_candidates_follow_declared_order() { + let rendered = codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "aarch64-apple-darwin", + ) + .expect("candidates") + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + rendered, + [ + "/app/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/app/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + ] + ); + assert!(codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "x86_64-unknown-linux-gnu" + ) + .is_err()); + } + + #[test] + fn plugin_rules_match_declared_whitelist() { + assert_eq!(PLUGINS.subdirectories.len(), 5); + assert_eq!(PLUGINS.subdirectories[0].path, "src"); + let payload = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "native/payload") + .expect("native/payload"); + assert!(subdirectory_enabled( + payload, + "x86_64-pc-windows-msvc", + |_| true + )); + assert!(!subdirectory_enabled( + payload, + "aarch64-apple-darwin", + |_| true + )); + let unity = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "dotnet/publish/win-x64") + .expect("unity publish"); + assert!(subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |name| name == "unity-editor-execute" + )); + assert!(!subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!subdirectory_enabled(unity, "aarch64-apple-darwin", |_| { + true + })); + assert!(plugin_staging_applies("x86_64-pc-windows-msvc")); + assert!(plugin_staging_applies("aarch64-apple-darwin")); + assert!(!plugin_staging_applies("x86_64-unknown-linux-gnu")); + assert_eq!(PLUGINS.source_directory, "plugins"); + assert_eq!(PLUGINS.destination_directory, "resources/plugins"); + assert_eq!(PLUGINS.manifest_file_name, "plugin.json"); + } + + #[test] + fn library_staging_rules_are_declared() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + assert_eq!(staging.plugin, "agc-godot-editor"); + assert_eq!(staging.source_subdirectory, "native/gdextension"); + assert!(library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute" + )); + assert!(!library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!library_staging_enabled( + staging, + "aarch64-apple-darwin", + |_| true + )); + } + + #[test] + fn skip_rules_match_copy_semantics() { + assert!(skip_directory("target")); + assert!(skip_directory("node_modules")); + assert!(skip_directory(".git")); + assert!(!skip_directory("src")); + assert!(skip_file_name(".env")); + assert!(skip_file_name("runner.test.mjs")); + assert!(!skip_file_name("runner.mjs")); + } +} 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 e0ceeb6da..a53d8da4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -4,6 +4,11 @@ #[path = "../build_support/godot_bundle.rs"] mod godot_bundle; +// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。 +#[cfg(test)] +#[path = "../build_support/package_layout.rs"] +mod package_layout; + use std::collections::BTreeMap; use std::fs; use std::fs::{File, OpenOptions}; diff --git a/package.json b/package.json index 87d45e193..2c852e22f 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,10 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", + "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", + "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", From d8b37e0da909068602ae27673d0b9b881138328e Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:03 +0800 Subject: [PATCH 09/60] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E5=87=86=E5=A4=87=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=EF=BC=88Codex=20=E4=B8=8E=E6=8F=92=E4=BB=B6=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E7=BA=AF=E5=A4=8D=E5=88=B6=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/prepare-bundled-resources.mjs:按声明解析目标与替换单位,用上游 package-lock 的 resolved/integrity 组成缓存 key,写临时目录后原子替换,命中缓存不重写任何文件,只替换本工具产物,失败即退出并给出可执行提示 - Codex 路径按声明复制组件、保留受版本控制的第三方声明、按现有字节格式生成 manifest.json;macOS 双架构整体 staging - 插件路径按声明的子目录白名单与跳过规则复制,顶层出现非本工具条目即失败 - 新增 scripts/prepare-bundled-resources.test.mjs:9 条用例覆盖 staging 结果、幂等(内容与时间戳)、上游缺失、目标不支持、目录被非本工具占用、dry-run 不落盘与白名单过滤 - .gitignore 忽略准备步骤产生的 staging 临时目录 --- .gitignore | 2 + .../scripts/prepare-bundled-resources.mjs | 870 ++++++++++++++++++ .../prepare-bundled-resources.test.mjs | 401 ++++++++ 3 files changed, 1273 insertions(+) create mode 100755 apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs create mode 100644 apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs diff --git a/.gitignore b/.gitignore index 5529773cd..6c7fc1d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ temp*build*/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs new file mode 100755 index 000000000..7c44d30d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -0,0 +1,870 @@ +#!/usr/bin/env node +// AGC 随包资源准备步骤:在 `tauri dev` / `tauri build` 之前把随包资源一次性 staging 到位。 +// +// 合同见 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md §4.3: +// 只替换本工具产物、写入临时目录后原子替换、命中缓存不重写任何文件、失败即关闭并给出可执行提示。 +// 布局与组件白名单来自唯一声明 src-tauri/build_support/package-layout.json(Node 与 Rust 共用)。 +// +// 本里程碑(M1)覆盖两条纯复制路径:随包 Codex CLI 与插件工作区。 +// 编辑器分支产物(Unity/Godot/Cocos)仍由构建脚本生成,归位在 M3。 +// +// 用法(在 apps/ai-game-creator-shell 下): +// node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] + +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defaultEditorFeatures } from './cargo-features.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +/** 应用 src-tauri 目录(随包资源的落点)。 */ +export const SRC_TAURI_DIR = path.join(APP_ROOT, 'src-tauri'); +/** 唯一的随包资源声明文件。 */ +export const DECLARATION_PATH = path.join( + SRC_TAURI_DIR, + 'build_support/package-layout.json', +); +/** 缓存记录(放在 gitignored 的 target 下,丢失只多一次哈希)。 */ +export const RECORD_PATH = path.join( + SRC_TAURI_DIR, + 'target/agc-resource-staging.json', +); + +const HOST_TRIPLES = new Map([ + ['darwin:arm64', 'aarch64-apple-darwin'], + ['darwin:x64', 'x86_64-apple-darwin'], + ['win32:x64', 'x86_64-pc-windows-msvc'], +]); + +class PrepareError extends Error {} + +function fail(message) { + throw new PrepareError(message); +} + +export function resolveHostTarget( + platform = process.platform, + arch = process.arch, +) { + const triple = HOST_TRIPLES.get(`${platform}:${arch}`); + if (!triple) { + fail( + `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, + ); + } + return triple; +} + +export function readDeclaration(declarationPath = DECLARATION_PATH) { + const declaration = JSON.parse(readFileSync(declarationPath, 'utf8')); + if (declaration.schema !== 'agc-package-layout.v1') { + fail(`不支持的随包资源声明 schema:${String(declaration.schema)}`); + } + return declaration; +} + +function codexLayout(declaration, target) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + if (!layout) { + fail(`随包资源声明不含目标 ${target} 的 Codex 布局`); + } + return layout; +} + +/// staging 的替换单位:属于整目录分组时替换整个分组目录(macOS 双架构共用),否则替换自身目录。 +export function stagingUnit(declaration, target) { + const layout = codexLayout(declaration, target); + const group = declaration.codex.universalGroups.find((entry) => + entry.targets.includes(target), + ); + const directory = group ? group.directory : layout.directory; + return { + directory, + targets: (group ? group.targets : [target]).map((member) => ({ + target: member, + layout: codexLayout(declaration, member), + })), + }; +} + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + createReadStream(file) + .on('data', (chunk) => hash.update(chunk)) + .on('end', () => resolve(hash.digest('hex'))) + .on('error', reject); + }); +} + +function sha256Text(text) { + return createHash('sha256').update(text).digest('hex'); +} + +function copyFilePreservingMode(source, destination) { + const info = statSync(source); + if (!info.isFile()) { + fail(`随包资源来源不是普通文件:${source}`); + } + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + chmodSync(destination, info.mode & 0o777); +} + +export function readRecord(recordPath = RECORD_PATH) { + if (!existsSync(recordPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(recordPath, 'utf8')); + } catch { + return {}; + } +} + +function writeRecord(record, recordPath) { + mkdirSync(path.dirname(recordPath), { recursive: true }); + writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); +} + +/// 上游平台包目录:按声明顺序取第一个「声明白名单文件齐全」的候选。 +export function findCodexSource(declaration, target, roots) { + const layout = codexLayout(declaration, target); + const candidates = []; + for (const rootName of declaration.codex.sourceRoots) { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + for (const relative of declaration.codex.sourceRelativePaths) { + candidates.push( + path.join( + root, + relative + .replaceAll('', layout.platform) + .replaceAll('', target), + ), + ); + } + } + const source = candidates.find((candidate) => + layout.files.every((entry) => existsSync(path.join(candidate, entry))), + ); + if (!source) { + fail( + `内置 Codex CLI 上游包缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return source; +} + +/// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 +export function readLockedUpstream( + declaration, + target, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), +) { + const layout = codexLayout(declaration, target); + const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); + const entry = + lockfile.packages?.[`node_modules/@openai/codex-${layout.platform}`]; + if (!entry?.resolved || !entry?.integrity) { + fail( + `package-lock.json 缺少 @openai/codex-${layout.platform} 的 resolved/integrity;请先在仓库根目录执行 npm ci`, + ); + } + return { resolved: entry.resolved, integrity: entry.integrity }; +} + +function serializeManifest(declaration, layout, hashes) { + const files = {}; + for (const relative of [...layout.files].sort()) { + files[relative] = hashes.get(relative); + } + return `${JSON.stringify( + { + files, + platform: layout.platform, + schemaVersion: declaration.codex.manifestSchema, + version: `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + }, + null, + 2, + )}\n`; +} + +function noticeSourceFor(declaration, target) { + const notice = declaration.codex.noticeSources.find((entry) => + entry.targets.includes(target), + ); + if (!notice) { + fail(`随包资源声明缺少目标 ${target} 的第三方声明来源`); + } + return notice; +} + +function unitPathOf(destinationRoot, declaration, unit) { + return path.join( + destinationRoot, + declaration.codex.resourceDirectory, + unit.directory, + ); +} + +function targetPathWithin(unitPath, declaration, unit, target) { + const layout = codexLayout(declaration, target); + const relative = path.relative(unit.directory, layout.directory); + return relative ? path.join(unitPath, relative) : unitPath; +} + +/// 该替换单位允许出现的顶层条目:整目录分组下是各架构子目录,单目标下是组件首段路径 + 第三方声明 + 清单。 +function allowedUnitEntries(declaration, unit) { + const allowed = new Set(); + for (const member of unit.targets) { + const relativeDirectory = path.relative( + unit.directory, + member.layout.directory, + ); + if (relativeDirectory) { + allowed.add(relativeDirectory.split(path.sep)[0]); + continue; + } + for (const relative of member.layout.files) { + allowed.add(relative.split('/')[0]); + } + allowed.add(declaration.codex.noticeFileName); + allowed.add(declaration.codex.manifestFileName); + } + return allowed; +} + +function assertOwnedUnit(unitPath, declaration, unit) { + if (!existsSync(unitPath)) { + return; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + const entryPath = path.join(unitPath, entry); + if (!allowed.has(entry)) { + fail( + `随包资源目录被非本工具内容占用:${entryPath};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 期望产物:每个目标的组件摘要与清单文本。 +async function desiredCodex(declaration, unit, roots) { + const desired = new Map(); + for (const member of unit.targets) { + const source = findCodexSource(declaration, member.target, roots); + const hashes = new Map(); + for (const relative of member.layout.files) { + hashes.set(relative, await sha256File(path.join(source, relative))); + } + desired.set(member.target, { + source, + files: new Map( + [...hashes].map(([relative, digest]) => [ + relative, + { sha256: digest, size: statSync(path.join(source, relative)).size }, + ]), + ), + manifest: serializeManifest(declaration, member.layout, hashes), + }); + } + return desired; +} + +async function unitMatchesExisting(unitPath, declaration, unit, desired) { + if (!existsSync(unitPath)) { + return false; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + return false; + } + } + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if ( + !existsSync(manifestPath) || + readFileSync(manifestPath, 'utf8') !== expected.manifest + ) { + return false; + } + for (const [relative, file] of expected.files) { + const filePath = path.join(targetDir, relative); + if (!existsSync(filePath)) { + return false; + } + const info = statSync(filePath); + if ( + info.size !== file.size || + (await sha256File(filePath)) !== file.sha256 + ) { + return false; + } + if (relative === member.layout.executable && (info.mode & 0o111) === 0) { + return false; + } + } + } + return true; +} + +function recordEntryFor(unitPath, declaration, unit, desired) { + const manifests = {}; + const sizes = {}; + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + manifests[member.target] = sha256Text(expected.manifest); + sizes[member.layout.directory] = Object.fromEntries( + [...expected.files].map(([relative, file]) => [relative, file.size]), + ); + void targetDir; + } + return { manifests, sizes }; +} + +async function unitRecordMatches(unitPath, declaration, unit, recorded) { + if (!recorded) { + return false; + } + for (const member of unit.targets) { + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if (!existsSync(manifestPath)) { + return false; + } + if ( + sha256Text(readFileSync(manifestPath, 'utf8')) !== + recorded.manifests?.[member.target] + ) { + return false; + } + const expectedSizes = recorded.sizes?.[member.layout.directory]; + if (!expectedSizes) { + return false; + } + for (const relative of member.layout.files) { + const file = path.join(targetDir, relative); + if ( + !existsSync(file) || + statSync(file).size !== expectedSizes[relative] + ) { + return false; + } + } + } + return true; +} + +function buildUnitInto( + stagingPath, + declaration, + unit, + desired, + unitPath, + destinationRoot, +) { + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + stagingPath, + declaration, + unit, + member.target, + ); + for (const relative of member.layout.files) { + copyFilePreservingMode( + path.join(expected.source, relative), + path.join(targetDir, relative), + ); + } + const notice = noticeSourceFor(declaration, member.target); + const noticeDestination = path.join( + targetDir, + declaration.codex.noticeFileName, + ); + const noticeSourcePath = notice.preserve + ? path.join( + targetPathWithin(unitPath, declaration, unit, member.target), + declaration.codex.noticeFileName, + ) + : path.join(destinationRoot, notice.source); + if (!existsSync(noticeSourcePath)) { + fail(`第三方声明来源缺失:${noticeSourcePath}`); + } + copyFilePreservingMode(noticeSourcePath, noticeDestination); + writeFileSync( + path.join(targetDir, declaration.codex.manifestFileName), + expected.manifest, + ); + } +} + +function stageAtomically(unitPath, builder) { + const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`; + rmSync(stagingPath, { recursive: true, force: true }); + mkdirSync(stagingPath, { recursive: true }); + try { + builder(stagingPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + rmSync(unitPath, { recursive: true, force: true }); + renameSync(stagingPath, unitPath); +} + +async function prepareCodex({ + declaration, + target, + destinationRoot, + roots, + lockfilePath, + record, + dryRun, +}) { + const unit = stagingUnit(declaration, target); + const label = `codex ${unit.targets.map((member) => member.target).join('+')}`; + const unitPath = unitPathOf(destinationRoot, declaration, unit); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + ...unit.targets.map( + (member) => + `${member.target}:${readLockedUpstream(declaration, member.target, lockfilePath).integrity}`, + ), + ].join('|'); + const recorded = record.codex?.[unit.directory]; + if ( + recorded?.key === key && + (await unitRecordMatches(unitPath, declaration, unit, recorded)) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + + const desired = await desiredCodex(declaration, unit, roots); + const entry = { + key, + ...recordEntryFor(unitPath, declaration, unit, desired), + }; + if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + assertOwnedUnit(unitPath, declaration, unit); + stageAtomically(unitPath, (staging) => + buildUnitInto( + staging, + declaration, + unit, + desired, + unitPath, + destinationRoot, + ), + ); + return { + summary: `${label} 重新生成(${unit.targets.length} 个架构,写入 ${declaration.codex.resourceDirectory}/${unit.directory})`, + record: entry, + }; +} + +export function pluginDirectories(declaration, repoRoot) { + const root = path.join(repoRoot, declaration.plugins.sourceDirectory); + if (!existsSync(root)) { + fail(`插件工作区缺失:${root}`); + } + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => ({ name: entry.name, root: path.join(root, entry.name) })) + .filter((plugin) => + existsSync(path.join(plugin.root, declaration.plugins.manifestFileName)), + ); +} + +function subdirectoryEnabled(subdirectory, target, features) { + const matchesContains = + !subdirectory.targetContains?.length || + subdirectory.targetContains.some((needle) => target.includes(needle)); + const matchesTarget = + !subdirectory.targets?.length || subdirectory.targets.includes(target); + const matchesFeatures = (subdirectory.features ?? []).every((name) => + features.has(name), + ); + return matchesContains && matchesTarget && matchesFeatures; +} + +function skipDirectory(declaration, name) { + return ( + declaration.plugins.skipDirectoryNames.includes(name) || + declaration.plugins.skipDirectoryNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) + ); +} + +function skipFileName(declaration, name) { + return ( + declaration.plugins.skipFileNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) || + declaration.plugins.skipFileNameFragments.some((fragment) => + name.includes(fragment), + ) + ); +} + +function walkFiles(declaration, root) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + stack.push([relative, entryPath]); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + files.push(relative); + } + } + } + return files.sort(); +} + +function copyPluginSubdirectory(declaration, source, destination) { + if (!existsSync(source)) { + return; + } + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source, { withFileTypes: true })) { + const entrySource = path.join(source, entry.name); + const entryDestination = path.join(destination, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entrySource}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + copyPluginSubdirectory(declaration, entrySource, entryDestination); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + copyFilePreservingMode(entrySource, entryDestination); + } + } +} + +function pluginSourceFingerprint(declaration, plugins, target, features) { + const lines = []; + for (const plugin of plugins) { + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const root = path.join(plugin.root, subdirectory.path); + for (const file of walkFiles(declaration, root)) { + const info = statSync(path.join(root, file)); + lines.push( + `${plugin.name}/${subdirectory.path}/${file}:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + } + const manifest = path.join( + plugin.root, + declaration.plugins.manifestFileName, + ); + const info = statSync(manifest); + lines.push( + `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + return sha256Text(lines.join('\n')); +} + +async function pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, +) { + if (!existsSync(destination)) { + return false; + } + for (const plugin of plugins) { + const pluginDestination = path.join(destination, plugin.name); + if ( + !existsSync( + path.join(pluginDestination, declaration.plugins.manifestFileName), + ) + ) { + return false; + } + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const sourceRoot = path.join(plugin.root, subdirectory.path); + for (const relative of walkFiles(declaration, sourceRoot)) { + const source = path.join(sourceRoot, relative); + const staged = path.join( + pluginDestination, + subdirectory.path, + relative, + ); + if (!existsSync(staged)) { + return false; + } + if (statSync(source).size !== statSync(staged).size) { + return false; + } + if ((await sha256File(source)) !== (await sha256File(staged))) { + return false; + } + } + } + } + return true; +} + +function assertOwnedPluginRoot(destination, plugins) { + if (!existsSync(destination)) { + return; + } + const known = new Set(plugins.map((plugin) => plugin.name)); + for (const entry of readdirSync(destination)) { + if (!known.has(entry)) { + fail( + `插件随包目录被非本工具内容占用:${path.join(destination, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +async function preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins, + record, + dryRun, +}) { + const destination = path.join( + destinationRoot, + declaration.plugins.destinationDirectory, + ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + if ( + record.plugins?.fingerprint === fingerprint && + (await pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, + )) + ) { + return { + summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + if (dryRun) { + return { + summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + assertOwnedPluginRoot(destination, plugins); + stageAtomically(destination, (staging) => { + for (const plugin of plugins) { + const pluginDestination = path.join(staging, plugin.name); + copyFilePreservingMode( + path.join(plugin.root, declaration.plugins.manifestFileName), + path.join(pluginDestination, declaration.plugins.manifestFileName), + ); + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(pluginDestination, subdirectory.path), + ); + } + } + }); + return { + summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, + record: { fingerprint }, + }; +} + +/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 +export async function prepareBundledResources({ + target = resolveHostTarget(), + destinationRoot = SRC_TAURI_DIR, + declarationPath = DECLARATION_PATH, + features = new Set(defaultEditorFeatures(target)), + recordPath = RECORD_PATH, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + dryRun = false, + repoRoot = REPO_ROOT, + appRoot = path.dirname(destinationRoot), +} = {}) { + const declaration = readDeclaration(declarationPath); + const record = readRecord(recordPath); + const summaries = []; + let changed = false; + const codex = await prepareCodex({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + lockfilePath, + record, + dryRun, + }); + summaries.push(codex.summary); + if (codex.record) { + record.codex = { + ...record.codex, + [stagingUnit(declaration, target).directory]: codex.record, + }; + changed = true; + } + if ( + declaration.plugins.targetContainsAny.some((needle) => + target.includes(needle), + ) + ) { + const plugins = await preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins: pluginDirectories(declaration, repoRoot), + record, + dryRun, + }); + summaries.push(plugins.summary); + if (plugins.record) { + record.plugins = plugins.record; + changed = true; + } + } else { + summaries.push(`plugins 跳过(目标 ${target} 不适用)`); + } + if (changed && !dryRun) { + writeRecord(record, recordPath); + } + return summaries; +} + +function parseArguments(argv) { + const args = { target: undefined, destinationRoot: undefined, dryRun: false }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === '--target') { + args.target = argv[index + 1]; + index += 1; + } else if (value === '--destination') { + args.destinationRoot = argv[index + 1]; + index += 1; + } else if (value === '--dry-run') { + args.dryRun = true; + } else { + fail(`未知参数:${value}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArguments(argv); + const summaries = await prepareBundledResources({ + target: args.target ?? resolveHostTarget(), + destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + dryRun: args.dryRun, + }); + for (const summary of summaries) { + console.log(`[agc-resources] ${summary}`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(process.argv.slice(2)).catch((error) => { + if (error instanceof PrepareError) { + console.error(`[agc-resources] ${error.message}`); + process.exit(1); + } + throw error; + }); +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs new file mode 100644 index 000000000..4876a3836 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + DECLARATION_PATH, + findCodexSource, + pluginDirectories, + prepareBundledResources, + readDeclaration, + resolveHostTarget, + stagingUnit, +} from './prepare-bundled-resources.mjs'; + +const WINDOWS_TARGET = 'x86_64-pc-windows-msvc'; +const MAC_TARGET = 'aarch64-apple-darwin'; + +function sha256File(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); + const appRoot = path.join(root, 'app'); + const repoRoot = path.join(root, 'repo'); + const destinationRoot = path.join(appRoot, 'src-tauri'); + const declaration = readDeclaration(DECLARATION_PATH); + const lockfile = { packages: {} }; + + for (const target of targets) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + assert.ok(layout, `声明缺少目标 ${target}`); + const vendor = path.join( + appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${target}`, + ); + for (const relative of layout.files) { + const file = path.join(vendor, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `component ${target} ${relative}\n`); + if (relative === layout.executable) { + fs.chmodSync(file, 0o755); + } + } + lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = { + resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`, + integrity: `sha512-${target}`, + }; + } + + fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), { + recursive: true, + }); + for (const entry of declaration.codex.noticeSources) { + if (entry.preserve) { + continue; + } + const file = path.join(destinationRoot, entry.source); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'mac codex notice\n'); + } + if (targets.includes(WINDOWS_TARGET)) { + const tracked = path.join( + destinationRoot, + 'resources/codex/win-x64/NOTICE.md', + ); + fs.mkdirSync(path.dirname(tracked), { recursive: true }); + fs.writeFileSync(tracked, 'windows codex notice\n'); + } + + if (plugins) { + const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor'); + fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + '{"name":"agc-demo-editor"}\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'src/entry.mjs'), + 'export const entry = 1;\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'panels/panel.html'), + '\n', + ); + fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n'); + fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); + fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); + fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + } + + const lockfilePath = path.join(root, 'package-lock.json'); + fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); + return { + root, + appRoot, + repoRoot, + destinationRoot, + lockfilePath, + recordPath: path.join(root, 'record.json'), + declaration, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +function snapshot(directory) { + const entries = []; + const stack = [['', directory]]; + while (stack.length > 0) { + const [prefix, current] = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push([relative, full]); + } else { + const info = fs.statSync(full); + entries.push({ + relative, + size: info.size, + mtimeMs: info.mtimeMs, + mode: info.mode & 0o777, + sha256: sha256File(full), + }); + } + } + } + return entries.sort((left, right) => + left.relative.localeCompare(right.relative), + ); +} + +function prepare(fixture, overrides = {}) { + return prepareBundledResources({ + target: WINDOWS_TARGET, + destinationRoot: fixture.destinationRoot, + declarationPath: DECLARATION_PATH, + recordPath: fixture.recordPath, + lockfilePath: fixture.lockfilePath, + repoRoot: fixture.repoRoot, + appRoot: fixture.appRoot, + ...overrides, + }); +} + +test('stages declared codex components with manifest and preserved notice', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture); + assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); + + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + layout.directory, + ); + for (const relative of layout.files) { + assert.ok( + fs.existsSync(path.join(unit, relative)), + `缺少组件 ${relative}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'), + 'windows codex notice\n', + '受版本控制的第三方声明必须原地保留', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'), + ); + assert.deepEqual(Object.keys(manifest), [ + 'files', + 'platform', + 'schemaVersion', + 'version', + ]); + assert.equal(manifest.platform, layout.platform); + assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema); + assert.equal( + manifest.version, + `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + ); + assert.deepEqual( + Object.keys(manifest.files).sort(), + [...layout.files].sort(), + '清单文件集合必须等于组件白名单', + ); + for (const relative of layout.files) { + assert.equal( + manifest.files[relative], + sha256File(path.join(unit, relative)), + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('second run is a no-op: identical content and timestamps', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + 'win-x64', + ); + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; + const summaries = await prepare(fixture); + assert.match(summaries[0], /命中缓存/); + assert.match(summaries[1], /命中缓存/); + assert.deepEqual( + snapshot(unit), + before.codex, + 'codex 产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(plugins), + before.plugins, + '插件产物内容与时间戳必须不变', + ); + } finally { + fixture.cleanup(); + } +}); + +test('stages the macOS universal group with both architectures', async () => { + const fixture = buildFixture({ + targets: [MAC_TARGET, 'x86_64-apple-darwin'], + }); + try { + const summaries = await prepare(fixture, { target: MAC_TARGET }); + assert.match(summaries[0], /mac-native/); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex/mac-native', + ); + for (const directory of ['darwin-arm64', 'darwin-x64']) { + for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) { + assert.ok( + fs.existsSync(path.join(unit, directory, file)), + `缺少 ${directory}/${file}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'), + 'mac codex notice\n', + ); + } + assert.deepEqual(fs.readdirSync(unit).sort(), [ + 'darwin-arm64', + 'darwin-x64', + ]); + } finally { + fixture.cleanup(); + } +}); + +test('copies only whitelisted plugin subdirectories', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const staged = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-demo-editor', + ); + assert.ok(fs.existsSync(path.join(staged, 'plugin.json'))); + assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs'))); + assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html'))); + assert.ok( + !fs.existsSync(path.join(staged, 'panels/panel.test.mjs')), + '测试文件不随包', + ); + assert.ok( + !fs.existsSync(path.join(staged, 'target')), + '构建产物目录不随包', + ); + assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包'); + assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包'); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package is missing', async () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { + recursive: true, + force: true, + }); + await assert.rejects(prepare(fixture), /npm ci/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '失败时不得留下半成品清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed for unsupported targets', async () => { + const fixture = buildFixture(); + try { + await assert.rejects( + prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + /声明不含目标/, + ); + assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the destination is owned by something else', async () => { + const fixture = buildFixture(); + try { + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + await assert.rejects(prepare(fixture), /被非本工具内容占用/); + + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); + fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { + recursive: true, + }); + await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('dry run writes nothing', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture, { dryRun: true }); + assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + assert.deepEqual( + fs.readdirSync(unit), + ['NOTICE.md'], + 'dry-run 不得写入任何组件或清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + assert.ok(!fs.existsSync(fixture.recordPath)); + } finally { + fixture.cleanup(); + } +}); + +test('declaration drives source lookup and staging units', () => { + const declaration = readDeclaration(DECLARATION_PATH); + const windows = stagingUnit(declaration, WINDOWS_TARGET); + assert.equal(windows.directory, 'win-x64'); + assert.deepEqual( + windows.targets.map((member) => member.target), + [WINDOWS_TARGET], + ); + const mac = stagingUnit(declaration, MAC_TARGET); + assert.equal(mac.directory, 'mac-native'); + assert.deepEqual( + mac.targets.map((member) => member.target), + ['aarch64-apple-darwin', 'x86_64-apple-darwin'], + ); + + const fixture = buildFixture(); + try { + const source = findCodexSource(declaration, WINDOWS_TARGET, { + app: fixture.appRoot, + repo: fixture.repoRoot, + }); + assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); + } finally { + fixture.cleanup(); + } +}); From 05d455d4ef5353d97660127494fb4aa6b3c1c818 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:04 +0800 Subject: [PATCH 10/60] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E5=A3=B0=E6=98=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E5=8F=AA=E8=AF=BB=E6=A0=A1=E9=AA=8C=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台布局、插件随包子目录与跳过规则、Codex 上游候选路径与第三方声明来源全部改由声明提供,删除脚本内的字面量 - 新增只读校验:Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接 - AGC_BUILD_TARGET 的 rustc-env 移到无条件路径(保留职责),新增 AGC_SKIP_RESOURCE_STAGING=1 以在既有产物上只跑校验 - 删除脚本内与 package_layout 重复的 sha256 实现,复用同一份 --- apps/ai-game-creator-shell/src-tauri/build.rs | 251 ++++++++++++------ 1 file changed, 167 insertions(+), 84 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index cd69f8180..52a397bce 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -15,35 +15,20 @@ use std::env; use std::fs; use std::path::PathBuf; -use std::io::{BufReader, Read}; - -fn sha256_file(path: &std::path::Path) -> Result { - let file = fs::File::open(path)?; - let mut reader = BufReader::new(file); - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = reader.read(&mut buffer)?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(format!("{:x}", hasher.finalize())) -} +use codex_bundle::package_layout; fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); - println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - if target.contains("apple-darwin") { + let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); + if let Some(group) = package_layout::codex_universal_group(&target) { // Tauri 的 universal 两次 Cargo 编译共用 resource staging, // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = manifest_dir.join("resources/codex/mac-native"); + let staging = resource_directory.join(group.directory); if staging.exists() { fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); } - for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { - stage_codex_target(manifest_dir, target); + for member in group.targets { + stage_codex_target(manifest_dir, member); } } else { stage_codex_target(manifest_dir, &target); @@ -66,18 +51,9 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { .parent() .and_then(|apps_dir| apps_dir.parent()) .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let package = format!("codex-{}", layout.platform); - let source_candidates = [app_root, repo_root] - .into_iter() - .flat_map(|root| { - [ - root.join(format!("node_modules/@openai/{package}/vendor/{target}")), - root.join(format!( - "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" - )), - ] - }) - .collect::>(); + let source_candidates = + package_layout::codex_source_candidates(app_root, repo_root, target) + .unwrap_or_else(|error| panic!("{error}")); let source = source_candidates .iter() .find(|path| { @@ -98,18 +74,23 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { ) }); let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + &fs::read(source.join(package_layout::codex().package_metadata_file_name)) + .expect("读取 Codex 原生包元数据失败"), ) .expect("Codex 原生包元数据无效"); codex_package_metadata::validate_package_metadata(&metadata, target, layout) .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir.join("resources/codex").join(layout.directory); - let notice = target_dir.join("NOTICE.md"); - if target.contains("apple-darwin") { - let source_notice = - manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + let notice = target_dir.join(package_layout::codex().notice_file_name); + if let Some(notice_source) = package_layout::codex_notice_source(target) { + if !notice_source.preserve { + // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 + let source_notice = manifest_dir.join(notice_source.source); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); @@ -122,9 +103,10 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); } - let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); + let source_sha256 = + package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); let target_matches_source = target_path.is_file() - && sha256_file(&target_path) + && package_layout::sha256_file(&target_path) .map(|target_sha256| target_sha256 == source_sha256) .unwrap_or(false); let source_permissions = fs::metadata(&source_path) @@ -156,7 +138,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); - let manifest_path = target_dir.join("manifest.json"); + let manifest_path = target_dir.join(package_layout::codex().manifest_file_name); let manifest_payload = format!( "{}\n", serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") @@ -217,16 +199,113 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu } } +/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。 +fn validate_staged_resources(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + for staged_target in package_layout::staged_targets(&target) { + let Some(layout) = codex_bundle::for_target(staged_target) else { + continue; + }; + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else( + |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"), + ); + } + validate_staged_plugin_workspace(manifest_dir, &target); +} + +/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 +/// +/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 +/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let destination_root = manifest_dir.join(declared.destination_directory); + if !destination_root.is_dir() { + panic!("插件随包资源目录缺失:{}", destination_root.display()); + } + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { + for entry in entries.flatten() { + let plugin_root = entry.path(); + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { + continue; + } + let name = entry.file_name(); + let staged = destination_root.join(&name); + if !staged.join(declared.manifest_file_name).is_file() { + panic!( + "随包插件缺少清单:{}", + staged.join(declared.manifest_file_name).display() + ); + } + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { + panic!( + "随包插件缺少必需目录:{}(插件 {})", + staged.join(&relative).display(), + name.to_string_lossy() + ); + } + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + if !staged.join(&relative).is_dir() { + panic!("随包库目录缺失:{}", staged.join(&relative).display()); + } + } + } + } + package_layout::collect_tree_files(&destination_root) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); +} + fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); - stage_bundled_codex_cli(&manifest_dir); - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); + // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。 + println!( + "cargo:rustc-env=AGC_BUILD_TARGET={}", + env::var("TARGET").expect("Cargo TARGET") + ); + // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), + // 用于在既有产物上单独验证校验路径。 + if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { + stage_bundled_codex_cli(&manifest_dir); + prepare_unity_editor_helper(&manifest_dir); + prepare_godot_editor_extension(&manifest_dir); + stage_plugin_workspace(&manifest_dir); + stage_cocos_editor_payload(&manifest_dir); + } + validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -435,17 +514,18 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { /// Cargo target 目录或 node_modules。 fn stage_plugin_workspace(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); - if !target.contains("windows") && !target.contains("apple-darwin") { + if !package_layout::plugin_staging_applies(&target) { return; } + let declared = package_layout::plugins(); let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) .expect("AGC 应用必须位于仓库 apps 目录下") .to_path_buf(); - let workspace = repo_root.join("plugins"); - let destination_root = manifest_dir.join("resources/plugins"); + let workspace = repo_root.join(declared.source_directory); + let destination_root = manifest_dir.join(declared.destination_directory); // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 if destination_root.exists() { std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); @@ -464,39 +544,47 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .is_symlink(), "插件工作区不允许符号链接" ); - if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { continue; } let name = entry.file_name(); let destination = destination_root.join(&name); copy_plugin_file( - &plugin_root.join("plugin.json"), - &destination.join("plugin.json"), + &plugin_root.join(declared.manifest_file_name), + &destination.join(declared.manifest_file_name), ); - for relative in [ - std::path::PathBuf::from("src"), - std::path::PathBuf::from("panels"), - std::path::PathBuf::from("skills"), - std::path::PathBuf::from("native/payload"), - std::path::PathBuf::from("dotnet/publish/win-x64"), - ] { - if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) - || (relative == std::path::Path::new("dotnet/publish/win-x64") - && (target != "x86_64-pc-windows-msvc" - || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + &target, + package_layout::cargo_feature_enabled, + ) { continue; } - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); - } - if name == "agc-godot-editor" { - godot_bundle::stage( - &plugin_root.join("native/gdextension"), - &destination.join("native/gdextension"), - &target, - env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(), - ) - .unwrap_or_else(|error| panic!("{error}")); + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + match staging.layout { + "godot-bundle" => godot_bundle::stage( + &plugin_root.join(&relative), + &destination.join(&relative), + &target, + true, + ) + .unwrap_or_else(|error| panic!("{error}")), + other => panic!("未实现的随包库 staging 布局:{other}"), + } } println!("cargo:rerun-if-changed={}", plugin_root.display()); } @@ -531,20 +619,15 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { ); if path.is_dir() { let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { + if package_layout::skip_directory(&name.to_string_lossy()) { continue; } std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); copy_plugin_tree(&path, &target); } else { - // 测试文件不随包分发。 + // 测试文件与隐藏文件不随包分发。 let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.contains(".test.") { - continue; - } - if name.starts_with('.') { + if package_layout::skip_file_name(&name.to_string_lossy()) { continue; } stage_plugin_file(&path, &target); From a4b103a1b09d7c009f201ce17357f01906e5e773 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:09 +0800 Subject: [PATCH 11/60] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E5=BD=92=E4=BD=8D=E7=9A=84=20M1=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术方案新增 §4.8 单一声明与实现形态(混合),§8 更新 M1 交付与停止条件,§9 未决问题 1 收口为已定 - M1 里程碑规范记录已定决策与校验收口边界;实施计划补充已定决策与实际验证命令 - M2 里程碑规范的依赖与前置条件补记 M1 已交付的准备步骤与声明门禁 - 开发运维文档补充声明、生成器、门禁、准备步骤与只读校验的用法;决策日志与排障经验同步本次口径 --- ...计划】AGC随包资源改由校验器读入-2026-09-26.md | 22 ++++++++++--------- ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 9 +++++++- ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 2 ++ .../shared-memory/decision-log.md | 10 +++++++++ docs/project-memory/shared-memory/pitfalls.md | 6 +++++ ...术方案】AGC随包资源staging归位-2026-09-26.md | 18 +++++++++++++-- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md index f609c1c72..8d664dba5 100644 --- a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -34,12 +34,14 @@ ## 验证命令 -1. 布局与白名单唯一性:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features codex_bundle` -2. 校验路径用例:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features <新增校验用例过滤名>` -3. 幂等:连续两次运行准备工具,比对目标目录内容与时间戳快照(内容与 mtime 均不变) -4. 失败关闭:对上游缺失、摘要篡改、非本工具目录、非目标平台四种场景各跑一次,记录错误输出 -5. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线) -6. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试 +1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。 +2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。 +3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 +4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。 +5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。 +6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。 +7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。 +8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。 ## 风险与回滚点 @@ -52,11 +54,11 @@ 回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 -## 待确认决策 +## 已定决策 -准备工具的实现形态(主规范未决问题 1)建议为**混合**: +准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): - 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); -- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具调用或由校验路径承担。 +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。 -理由:避免出现第二份组件白名单;同时不必重写 registry 下载、`integrity` 与 tar 安全校验。若评审倾向单一语言实现,需接受「另写一份白名单」或「把 Node 侧下载能力用 Rust 重写」二者之一。 +理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md index 57c6d8e2a..62bb4160e 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -3,10 +3,17 @@ | 字段 | 值 | | ----------- | ----------------------------------------------------------- | | Version | 1.0 | -| Status | proposed | +| Status | in-progress(2026-09-27 起实施 M1) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | +## 已定决策(2026-09-27) + +- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。 +- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。 +- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。 +- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。 + ## 目标 构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 1f70aa288..72d4a340b 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -28,6 +28,7 @@ ## 依赖与前置条件 - 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。 - 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 - Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 @@ -44,3 +45,4 @@ - 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 - 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 - 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 +1 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 28d8e2a64..43d8879a5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9578,3 +9578,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 + +## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」 + +- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。 +- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。 +- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。 +- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 +- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 +- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 712b30ac6..a7e6b7763 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 + +- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 +- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 +- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 + ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 0ec2225b0..72f9f4c53 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -144,6 +144,20 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 +### 4.8 单一声明与实现形态(M1 定案) + +准备步骤与构建期校验共用一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。 + +| 侧 | 读取方式 | 用途 | +| --- | --- | --- | +| Node 准备步骤(`scripts/prepare-bundled-resources.mjs`) | 直接读声明 JSON | 组件白名单、Codex 上游候选路径、插件随包子目录与跳过规则、平台与 feature 门槛、缓存 key 组成、manifest 序列化 | +| Rust 构建期校验与运行期布局 | 读声明生成的 `build_support/package-layout.generated.rs`(编译期常量) | 只读校验既有产物、运行期定位随包组件(`codex_bundle.rs` 接口不变) | +| 声明门禁 | `scripts/check-package-layout.mjs`(`npm run agc:bundled-resources:check`,已进 `agc:typecheck` 链) | 校验生成物与声明一致,并检查声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源、`codex.version` 与应用锁定的 `@openai/codex` 一致 | + +形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 + +准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 + ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 @@ -181,7 +195,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | 里程碑 | 交付 | 停止条件 | |---|---|---| -| M1 校验器化 | `build.rs` 增加只读校验路径、准备步骤脚本骨架(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿,且不改变现有构建行为 | +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | | M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | @@ -189,6 +203,6 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 ## 9. 未决问题 -1. 准备步骤用 Rust bin(可复用 `codex_bundle.rs` 的布局表与校验)还是 Node 脚本(与 `stage-node-runtime.mjs` 同构)?倾向前者以复用布局表与 sha256 校验,避免第二份白名单。 +1. ~~准备步骤用 Rust bin 还是 Node 脚本?~~ **已定(2026-09-27):混合**——生成归 Node、声明与校验归 Rust,布局与白名单收敛为单一声明文件。机制、门禁与验证入口见 §4.8。 2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0d39c0ee9..ca2b87150 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -86,6 +86,8 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- `npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 +AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From f6dad1950f24207deb8cb05743527fddbe79d055 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:56:44 +0800 Subject: [PATCH 12/60] =?UTF-8?q?AGC=20=E9=9A=8F=E5=8C=85=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=87=86=E5=A4=87=E6=AD=A5=E9=AA=A4=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20dev=20=E4=B8=8E=E5=8F=91=E5=B8=83=E5=85=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E9=80=80=E5=87=BA=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序 - build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging - 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads - 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测 - 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除 - 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程 - 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed - 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步 --- .../scripts/build-release.mjs | 27 +- .../scripts/build-release.test.mjs | 19 +- .../scripts/check-package-layout.mjs | 35 ++ .../scripts/prepare-bundled-resources.mjs | 120 ++++-- .../prepare-bundled-resources.test.mjs | 56 ++- .../scripts/start-tauri-dev.mjs | 33 +- apps/ai-game-creator-shell/src-tauri/build.rs | 344 +++--------------- .../build_support/codex_package_metadata.rs | 57 --- .../build_support/package-layout.generated.rs | 10 + .../build_support/package-layout.json | 14 +- .../src-tauri/build_support/package_layout.rs | 310 ++++++++++++++++ .../src-tauri/src/agent/codex_cli.rs | 5 - .../tests/start-tauri-dev.test.ts | 13 +- ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 2 +- ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 6 +- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 6 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 12 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 19 files changed, 661 insertions(+), 420 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fe13fade7..e6ddb30cd 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -18,6 +18,7 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; +import { prepareBundledResources } from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -431,10 +432,29 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { return configPath; } +/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 +export function stageBundledResources( + target, + { prepare = prepareBundledResources } = {}, +) { + const summaries = prepare({ + target, + features: new Set(defaultEditorFeatures(target)), + log: (line) => console.log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + console.log(`[ai-game-creator-shell] ${summary}`); + } +} + export function runTauriBuild( args = [], context = resolveReleaseContext(args), - { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {}, + { + spawn = spawnSync, + stageRuntime = stageNodeRuntime, + stageBundled = stageBundledResources, + } = {}, ) { if ( explicitBuildTarget(args) && @@ -444,7 +464,10 @@ export function runTauriBuild( } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; - if (!args.includes('--no-bundle')) stageRuntime(target); + if (!args.includes('--no-bundle')) { + stageRuntime(target); + stageBundled(target); + } const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 3cdd735c1..3d046d9d8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -363,6 +363,8 @@ test('packaged renderer receives the same channel as the updater manifest', () = // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 stageRuntime: () => {}, + // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。 + stageBundled: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; @@ -456,6 +458,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and seenContexts.push(context); runTauriBuild(args, context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( @@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { stageRuntime: () => {}, + stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( @@ -818,6 +823,10 @@ test('release stages Node before Tauri and injects its resource mapping only for assert.equal(target, windowsTarget); events.push('stage'); }, + stageBundled(target) { + assert.equal(target, windowsTarget); + events.push('bundled'); + }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( @@ -829,11 +838,14 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); - assert.deepEqual(events, ['stage', 'build']); + assert.deepEqual(events, ['stage', 'bundled', 'build']); runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { assert.fail('no-bundle must not stage resources'); }, + stageBundled() { + assert.fail('no-bundle must not stage bundled resources'); + }, spawn(_binary, args) { const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), @@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for stageRuntime() { throw new Error('missing runtime'); }, + stageBundled() { + assert.fail('invalid runtime must prevent bundled staging'); + }, spawn() { assert.fail('invalid runtime must prevent build'); }, @@ -960,6 +975,8 @@ for (const channel of ['release', 'beta-2']) { ); runTauriBuild([`--target=${target}`], context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const config = JSON.parse( readFileSync( diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index d3a5e1237..023f19ae4 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -81,6 +81,15 @@ function expectInteger(value, at) { return value; } +// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责), +// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。 +function expectOrigin(value, at) { + if (value !== 'source' && value !== 'build') { + fail(`${at} 必须是 source 或 build(实际 ${String(value)})`); + } + return value; +} + // JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 function rustString(value) { return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); @@ -101,6 +110,21 @@ function renderStruct(name, fields, level = 0) { return `${name} {\n${body}\n${indent(level)}}`; } +function parsePackageMetadata(value) { + const metadata = expectObject(value, 'codex.packageMetadata'); + return { + layoutVersion: expectInteger( + metadata.layoutVersion, + 'codex.packageMetadata.layoutVersion', + ), + resourcesDir: expectString( + metadata.resourcesDir, + 'codex.packageMetadata.resourcesDir', + ), + pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'), + }; +} + function parseDeclaration(raw) { const root = expectObject(JSON.parse(raw), 'root'); if (root.schema !== EXPECTED_SCHEMA) { @@ -191,6 +215,7 @@ function parseDeclaration(raw) { const parsed = expectObject(entry, at); return { path: expectString(parsed.path, `${at}.path`), + origin: expectOrigin(parsed.origin, `${at}.origin`), targetContains: optionalStringArray(parsed, 'targetContains', at), targets: optionalStringArray(parsed, 'targets', at), features: optionalStringArray(parsed, 'features', at), @@ -226,6 +251,7 @@ function parseDeclaration(raw) { codex.manifestSchema, 'codex.manifestSchema', ), + packageMetadata: parsePackageMetadata(codex.packageMetadata), resourceDirectory: expectString( codex.resourceDirectory, 'codex.resourceDirectory', @@ -333,6 +359,7 @@ function renderSubdirectory(entry) { 'Subdirectory', [ ['path', rustString(entry.path)], + ['origin', rustString(entry.origin)], ['target_contains', rustStrings(entry.targetContains)], ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], @@ -359,6 +386,14 @@ function renderGenerated(declaration) { const codex = declaration.codex; const plugins = declaration.plugins; const codexStruct = renderStruct('Codex', [ + [ + 'package_metadata', + renderStruct('PackageMetadata', [ + ['layout_version', String(codex.packageMetadata.layoutVersion)], + ['resources_dir', rustString(codex.packageMetadata.resourcesDir)], + ['path_dir', rustString(codex.packageMetadata.pathDir)], + ]), + ], ['resource_directory', rustString(codex.resourceDirectory)], ['manifest_file_name', rustString(codex.manifestFileName)], ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 7c44d30d4..b5538138f 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -14,12 +14,14 @@ import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, + closeSync, copyFileSync, - createReadStream, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, renameSync, rmSync, statSync, @@ -58,11 +60,19 @@ function fail(message) { throw new PrepareError(message); } +/// 声明覆盖的宿主目标;不受声明覆盖的平台返回 null(入口据此跳过资源准备)。 +export function supportedHostTarget( + platform = process.platform, + arch = process.arch, +) { + return HOST_TRIPLES.get(`${platform}:${arch}`) ?? null; +} + export function resolveHostTarget( platform = process.platform, arch = process.arch, ) { - const triple = HOST_TRIPLES.get(`${platform}:${arch}`); + const triple = supportedHostTarget(platform, arch); if (!triple) { fail( `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, @@ -106,13 +116,21 @@ export function stagingUnit(declaration, target) { } function sha256File(file) { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - createReadStream(file) - .on('data', (chunk) => hash.update(chunk)) - .on('end', () => resolve(hash.digest('hex'))) - .on('error', reject); - }); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + const descriptor = openSync(file, 'r'); + try { + for (;;) { + const read = readSync(descriptor, buffer, 0, buffer.length, null); + if (read === 0) { + break; + } + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } + return hash.digest('hex'); } function sha256Text(text) { @@ -176,6 +194,29 @@ export function findCodexSource(declaration, target, roots) { return source; } +/// 上游原生包元数据必须与声明一致:版本、目标、入口、资源目录与 path 目录。 +export function validateUpstreamMetadata(declaration, target, source) { + const metadataFile = path.join( + source, + declaration.codex.packageMetadataFileName, + ); + const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + const layout = codexLayout(declaration, target); + const expected = declaration.codex.packageMetadata; + const mismatch = + metadata.layoutVersion !== expected.layoutVersion || + metadata.version !== declaration.codex.version || + metadata.target !== target || + metadata.entrypoint !== layout.executable || + metadata.resourcesDir !== expected.resourcesDir || + metadata.pathDir !== expected.pathDir; + if (mismatch) { + fail( + `内置 Codex CLI 上游包元数据与声明不一致(${metadataFile}):期望 layoutVersion=${expected.layoutVersion} version=${declaration.codex.version} target=${target} entrypoint=${layout.executable};请确认上游包版本后再同步 build_support/package-layout.json`, + ); + } +} + /// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 export function readLockedUpstream( declaration, @@ -272,13 +313,14 @@ function assertOwnedUnit(unitPath, declaration, unit) { } /// 期望产物:每个目标的组件摘要与清单文本。 -async function desiredCodex(declaration, unit, roots) { +function desiredCodex(declaration, unit, roots) { const desired = new Map(); for (const member of unit.targets) { const source = findCodexSource(declaration, member.target, roots); + validateUpstreamMetadata(declaration, member.target, source); const hashes = new Map(); for (const relative of member.layout.files) { - hashes.set(relative, await sha256File(path.join(source, relative))); + hashes.set(relative, sha256File(path.join(source, relative))); } desired.set(member.target, { source, @@ -294,7 +336,7 @@ async function desiredCodex(declaration, unit, roots) { return desired; } -async function unitMatchesExisting(unitPath, declaration, unit, desired) { +function unitMatchesExisting(unitPath, declaration, unit, desired) { if (!existsSync(unitPath)) { return false; } @@ -328,10 +370,7 @@ async function unitMatchesExisting(unitPath, declaration, unit, desired) { return false; } const info = statSync(filePath); - if ( - info.size !== file.size || - (await sha256File(filePath)) !== file.sha256 - ) { + if (info.size !== file.size || sha256File(filePath) !== file.sha256) { return false; } if (relative === member.layout.executable && (info.mode & 0o111) === 0) { @@ -362,7 +401,7 @@ function recordEntryFor(unitPath, declaration, unit, desired) { return { manifests, sizes }; } -async function unitRecordMatches(unitPath, declaration, unit, recorded) { +function unitRecordMatches(unitPath, declaration, unit, recorded) { if (!recorded) { return false; } @@ -461,7 +500,7 @@ function stageAtomically(unitPath, builder) { renameSync(stagingPath, unitPath); } -async function prepareCodex({ +function prepareCodex({ declaration, target, destinationRoot, @@ -483,17 +522,17 @@ async function prepareCodex({ const recorded = record.codex?.[unit.directory]; if ( recorded?.key === key && - (await unitRecordMatches(unitPath, declaration, unit, recorded)) + unitRecordMatches(unitPath, declaration, unit, recorded) ) { return { summary: `${label} 命中缓存(未写入)`, record: undefined }; } - const desired = await desiredCodex(declaration, unit, roots); + const desired = desiredCodex(declaration, unit, roots); const entry = { key, ...recordEntryFor(unitPath, declaration, unit, desired), }; - if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + if (unitMatchesExisting(unitPath, declaration, unit, desired)) { return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; } if (dryRun) { @@ -615,6 +654,9 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { const lines = []; for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -638,7 +680,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { return sha256Text(lines.join('\n')); } -async function pluginTreeMatches( +function pluginTreeMatches( declaration, plugins, target, @@ -658,6 +700,9 @@ async function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -675,7 +720,7 @@ async function pluginTreeMatches( if (statSync(source).size !== statSync(staged).size) { return false; } - if ((await sha256File(source)) !== (await sha256File(staged))) { + if (sha256File(source) !== sha256File(staged)) { return false; } } @@ -698,7 +743,7 @@ function assertOwnedPluginRoot(destination, plugins) { } } -async function preparePlugins({ +function preparePlugins({ declaration, target, destinationRoot, @@ -719,13 +764,7 @@ async function preparePlugins({ ); if ( record.plugins?.fingerprint === fingerprint && - (await pluginTreeMatches( - declaration, - plugins, - target, - features, - destination, - )) + pluginTreeMatches(declaration, plugins, target, features, destination) ) { return { summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, @@ -747,6 +786,9 @@ async function preparePlugins({ path.join(pluginDestination, declaration.plugins.manifestFileName), ); for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -765,7 +807,7 @@ async function preparePlugins({ } /// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 -export async function prepareBundledResources({ +export function prepareBundledResources({ target = resolveHostTarget(), destinationRoot = SRC_TAURI_DIR, declarationPath = DECLARATION_PATH, @@ -780,7 +822,7 @@ export async function prepareBundledResources({ const record = readRecord(recordPath); const summaries = []; let changed = false; - const codex = await prepareCodex({ + const codex = prepareCodex({ declaration, target, destinationRoot, @@ -802,7 +844,7 @@ export async function prepareBundledResources({ target.includes(needle), ) ) { - const plugins = await preparePlugins({ + const plugins = preparePlugins({ declaration, target, destinationRoot, @@ -844,9 +886,9 @@ function parseArguments(argv) { return args; } -async function main(argv) { +function main(argv) { const args = parseArguments(argv); - const summaries = await prepareBundledResources({ + const summaries = prepareBundledResources({ target: args.target ?? resolveHostTarget(), destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, dryRun: args.dryRun, @@ -860,11 +902,13 @@ if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { - main(process.argv.slice(2)).catch((error) => { + try { + main(process.argv.slice(2)); + } catch (error) { if (error instanceof PrepareError) { console.error(`[agc-resources] ${error.message}`); process.exit(1); } throw error; - }); + } } diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 4876a3836..b3cf4479b 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -43,7 +43,23 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { for (const relative of layout.files) { const file = path.join(vendor, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `component ${target} ${relative}\n`); + fs.writeFileSync( + file, + relative === declaration.codex.packageMetadataFileName + ? `${JSON.stringify( + { + layoutVersion: declaration.codex.packageMetadata.layoutVersion, + version: declaration.codex.version, + target, + entrypoint: layout.executable, + resourcesDir: declaration.codex.packageMetadata.resourcesDir, + pathDir: declaration.codex.packageMetadata.pathDir, + }, + null, + 2, + )}\n` + : `component ${target} ${relative}\n`, + ); if (relative === layout.executable) { fs.chmodSync(file, 0o755); } @@ -152,10 +168,10 @@ function prepare(fixture, overrides = {}) { }); } -test('stages declared codex components with manifest and preserved notice', async () => { +test('stages declared codex components with manifest and preserved notice', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); const declaration = fixture.declaration; @@ -209,10 +225,10 @@ test('stages declared codex components with manifest and preserved notice', asyn } }); -test('second run is a no-op: identical content and timestamps', async () => { +test('second run is a no-op: identical content and timestamps', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const unit = path.join( fixture.destinationRoot, 'resources/codex', @@ -220,7 +236,7 @@ test('second run is a no-op: identical content and timestamps', async () => { ); const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /命中缓存/); assert.match(summaries[1], /命中缓存/); assert.deepEqual( @@ -238,12 +254,12 @@ test('second run is a no-op: identical content and timestamps', async () => { } }); -test('stages the macOS universal group with both architectures', async () => { +test('stages the macOS universal group with both architectures', () => { const fixture = buildFixture({ targets: [MAC_TARGET, 'x86_64-apple-darwin'], }); try { - const summaries = await prepare(fixture, { target: MAC_TARGET }); + const summaries = prepare(fixture, { target: MAC_TARGET }); assert.match(summaries[0], /mac-native/); const unit = path.join( fixture.destinationRoot, @@ -270,10 +286,10 @@ test('stages the macOS universal group with both architectures', async () => { } }); -test('copies only whitelisted plugin subdirectories', async () => { +test('copies only whitelisted plugin subdirectories', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const staged = path.join( fixture.destinationRoot, 'resources/plugins/agc-demo-editor', @@ -296,14 +312,14 @@ test('copies only whitelisted plugin subdirectories', async () => { } }); -test('fails closed when the upstream package is missing', async () => { +test('fails closed when the upstream package is missing', () => { const fixture = buildFixture(); try { fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { recursive: true, force: true, }); - await assert.rejects(prepare(fixture), /npm ci/); + assert.throws(() => prepare(fixture), /npm ci/); assert.ok( !fs.existsSync( path.join( @@ -321,11 +337,11 @@ test('fails closed when the upstream package is missing', async () => { } }); -test('fails closed for unsupported targets', async () => { +test('fails closed for unsupported targets', () => { const fixture = buildFixture(); try { - await assert.rejects( - prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + assert.throws( + () => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), /声明不含目标/, ); assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); @@ -334,28 +350,28 @@ test('fails closed for unsupported targets', async () => { } }); -test('fails closed when the destination is owned by something else', async () => { +test('fails closed when the destination is owned by something else', () => { const fixture = buildFixture(); try { const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); - await assert.rejects(prepare(fixture), /被非本工具内容占用/); + assert.throws(() => prepare(fixture), /被非本工具内容占用/); const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { recursive: true, }); - await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/); } finally { fixture.cleanup(); } }); -test('dry run writes nothing', async () => { +test('dry run writes nothing', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture, { dryRun: true }); + const summaries = prepare(fixture, { dryRun: true }); assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); assert.deepEqual( diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 3d72403ae..47a6eaeb3 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -11,6 +11,10 @@ import { resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { isAiGameCreatorServer, preflightExistingVite, @@ -68,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { return withDefaultCargoFeatures(argv, features); } +/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 +/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +function prepareBundledResourcesBeforeTauri( + features = readDevCargoFeatures(), + { prepare = prepareBundledResources, log = console.log } = {}, +) { + const target = supportedHostTarget(); + if (!target) { + log( + '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', + ); + return; + } + const summaries = prepare({ + target, + features: new Set(features), + log: (line) => log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + log(`[ai-game-creator-shell] ${summary}`); + } +} + function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, @@ -96,6 +123,7 @@ async function runTauriDev( spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, + prepareResources = prepareBundledResourcesBeforeTauri, } = {}, ) { const endpoint = await resolveDevEndpoint(); @@ -142,8 +170,10 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; + const devFeatures = readDevCargoFeatures(); + prepareResources(devFeatures); const tauriArguments = buildTauriArguments( - withDevCargoFeatures(argv), + withDevCargoFeatures(argv, devFeatures), endpoint.url, ); child = spawnCli(tauriArguments, { @@ -236,6 +266,7 @@ export { buildTauriArguments, buildTauriDevProcessEnv, isDirectModuleExecution, + prepareBundledResourcesBeforeTauri, runTauriDev, spawnTauriCli, withDevCargoFeatures, diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 52a397bce..948438b62 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,7 +1,8 @@ +// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤), +// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。 +#[allow(dead_code)] #[path = "build_support/codex_bundle.rs"] mod codex_bundle; -#[path = "build_support/codex_package_metadata.rs"] -mod codex_package_metadata; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/godot_bundle.rs"] @@ -17,145 +18,6 @@ use std::path::PathBuf; use codex_bundle::package_layout; -fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); - if let Some(group) = package_layout::codex_universal_group(&target) { - // Tauri 的 universal 两次 Cargo 编译共用 resource staging, - // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = resource_directory.join(group.directory); - if staging.exists() { - fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); - } - for member in group.targets { - stage_codex_target(manifest_dir, member); - } - } else { - stage_codex_target(manifest_dir, &target); - } -} - -fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { - let Some(layout) = codex_bundle::for_target(target) else { - assert!( - !target.contains("windows") && !target.contains("apple-darwin"), - "不支持的 Codex 随包目标:{target}" - ); - return; - }; - { - let app_root = manifest_dir - .parent() - .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let source_candidates = - package_layout::codex_source_candidates(app_root, repo_root, target) - .unwrap_or_else(|error| panic!("{error}")); - let source = source_candidates - .iter() - .find(|path| { - layout - .files - .iter() - .all(|relative| path.join(relative).is_file()) - }) - .cloned() - .unwrap_or_else(|| { - panic!( - "内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})", - source_candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(";") - ) - }); - let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join(package_layout::codex().package_metadata_file_name)) - .expect("读取 Codex 原生包元数据失败"), - ) - .expect("Codex 原生包元数据无效"); - codex_package_metadata::validate_package_metadata(&metadata, target, layout) - .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir - .join(package_layout::codex().resource_directory) - .join(layout.directory); - let notice = target_dir.join(package_layout::codex().notice_file_name); - if let Some(notice_source) = package_layout::codex_notice_source(target) { - if !notice_source.preserve { - // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 - let source_notice = manifest_dir.join(notice_source.source); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); - } - } - if !notice.is_file() { - panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); - } - fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); - let mut file_hashes = serde_json::Map::new(); - for relative in layout.files { - let source_path = source.join(relative); - let target_path = target_dir.join(relative); - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); - } - let source_sha256 = - package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); - let target_matches_source = target_path.is_file() - && package_layout::sha256_file(&target_path) - .map(|target_sha256| target_sha256 == source_sha256) - .unwrap_or(false); - let source_permissions = fs::metadata(&source_path) - .expect("读取组件权限失败") - .permissions(); - if !target_matches_source { - fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); - fs::set_permissions(&target_path, source_permissions.clone()) - .expect("保留内置 Codex CLI 组件权限失败"); - } else if fs::metadata(&target_path) - .expect("读取内置 Codex CLI 资源失败") - .permissions() - != source_permissions - { - // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 - // 权限已一致时不再写元数据:Windows 上这次写入会更新 change time, - // 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。 - fs::set_permissions(&target_path, source_permissions) - .expect("保留内置 Codex CLI 组件权限失败"); - } - file_hashes.insert( - relative.to_string(), - serde_json::Value::String(source_sha256), - ); - } - let manifest = serde_json::json!({ - "schemaVersion": codex_bundle::SCHEMA, - "platform": layout.platform, - "version": codex_bundle::CLI_VERSION, - "files": file_hashes, - }); - let manifest_path = target_dir.join(package_layout::codex().manifest_file_name); - let manifest_payload = format!( - "{}\n", - serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") - ); - if fs::read_to_string(&manifest_path) - .map(|current| current != manifest_payload) - .unwrap_or(true) - { - fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); - } - for relative in layout.files { - println!("cargo:rerun-if-changed={}", source.join(relative).display()); - } - println!("cargo:rerun-if-changed={}", notice.display()); - } -} - fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, ) -> &'static str { @@ -216,76 +78,22 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) { validate_staged_plugin_workspace(manifest_dir, &target); } -/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 -/// -/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 -/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { - if !package_layout::plugin_staging_applies(target) { - return; - } let declared = package_layout::plugins(); - let destination_root = manifest_dir.join(declared.destination_directory); - if !destination_root.is_dir() { - panic!("插件随包资源目录缺失:{}", destination_root.display()); - } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) .expect("AGC 应用必须位于仓库 apps 目录下"); - if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { - for entry in entries.flatten() { - let plugin_root = entry.path(); - if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let staged = destination_root.join(&name); - if !staged.join(declared.manifest_file_name).is_file() { - panic!( - "随包插件缺少清单:{}", - staged.join(declared.manifest_file_name).display() - ); - } - for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - target, - package_layout::cargo_feature_enabled, - ) { - continue; - } - let relative = package_layout::declared_relative_path(subdirectory.path); - if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { - panic!( - "随包插件缺少必需目录:{}(插件 {})", - staged.join(&relative).display(), - name.to_string_lossy() - ); - } - } - for staging in declared.library_staging { - if name != staging.plugin - || !package_layout::library_staging_enabled( - staging, - target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(staging.source_subdirectory); - if !staged.join(&relative).is_dir() { - panic!("随包库目录缺失:{}", staged.join(&relative).display()); - } - } - } - } - package_layout::collect_tree_files(&destination_root) - .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); + package_layout::validate_staged_plugins( + &repo_root.join(declared.source_directory), + &manifest_dir.join(declared.destination_directory), + target, + package_layout::cargo_feature_enabled, + ) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); } - fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), @@ -299,10 +107,9 @@ fn main() { // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), // 用于在既有产物上单独验证校验路径。 if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { - stage_bundled_codex_cli(&manifest_dir); prepare_unity_editor_helper(&manifest_dir); prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); + stage_build_generated_plugin_payloads(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); } validate_staged_resources(&manifest_dir); @@ -508,11 +315,11 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); } -/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 +/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。 /// -/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 -/// Cargo target 目录或 node_modules。 -fn stage_plugin_workspace(manifest_dir: &std::path::Path) { +/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。 +/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。 +fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); if !package_layout::plugin_staging_applies(&target) { return; @@ -522,50 +329,36 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下") - .to_path_buf(); - let workspace = repo_root.join(declared.source_directory); + .expect("AGC 应用必须位于仓库 apps 目录下"); let destination_root = manifest_dir.join(declared.destination_directory); - // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 - if destination_root.exists() { - std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); - } - std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); - let entries = match std::fs::read_dir(&workspace) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let plugin_root = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件目录类型失败") - .is_symlink(), - "插件工作区不允许符号链接" - ); - if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let destination = destination_root.join(&name); - copy_plugin_file( - &plugin_root.join(declared.manifest_file_name), - &destination.join(declared.manifest_file_name), - ); + let plugins = package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")); + for plugin in plugins { for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - &target, - package_layout::cargo_feature_enabled, - ) { + if !package_layout::subdirectory_is_build_derived(subdirectory) + || !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) + { continue; } let relative = package_layout::declared_relative_path(subdirectory.path); - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + let source = plugin.path.join(&relative); + if !source.is_dir() { + continue; + } + copy_staged_tree( + &source, + &destination_root.join(&plugin.name).join(&relative), + ); } for staging in declared.library_staging { - if name != staging.plugin + if plugin.name != staging.plugin || !package_layout::library_staging_enabled( staging, &target, @@ -577,8 +370,8 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { let relative = package_layout::declared_relative_path(staging.source_subdirectory); match staging.layout { "godot-bundle" => godot_bundle::stage( - &plugin_root.join(&relative), - &destination.join(&relative), + &plugin.path.join(&relative), + &destination_root.join(&plugin.name).join(&relative), &target, true, ) @@ -586,60 +379,37 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { other => panic!("未实现的随包库 staging 布局:{other}"), } } - println!("cargo:rerun-if-changed={}", plugin_root.display()); } } -fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let bytes = std::fs::read(source) - .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); - if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { +/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。 +fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) { + if !source.is_dir() { return; } - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent).expect("创建插件资源目录失败"); - } - std::fs::write(destination, bytes).expect("复制插件资源失败"); -} - -fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { - let entries = match std::fs::read_dir(source) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let target = destination.join(entry.file_name()); + fs::create_dir_all(destination).expect("创建插件资源目录失败"); + for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() { let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let target = destination.join(&file_name); assert!( - !entry + entry .file_type() .expect("读取插件文件类型失败") .is_symlink(), "插件资源不允许符号链接" ); if path.is_dir() { - let name = entry.file_name(); - if package_layout::skip_directory(&name.to_string_lossy()) { + if !package_layout::skip_directory(&name) { + copy_staged_tree(&path, &target); + } + } else if !package_layout::skip_file_name(&name) { + let bytes = fs::read(&path).expect("读取插件资源失败"); + if fs::read(&target).is_ok_and(|existing| existing == bytes) { continue; } - std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); - copy_plugin_tree(&path, &target); - } else { - // 测试文件与隐藏文件不随包分发。 - let name = entry.file_name(); - if package_layout::skip_file_name(&name.to_string_lossy()) { - continue; - } - stage_plugin_file(&path, &target); + fs::write(&target, bytes).expect("复制插件资源失败"); } } } - -fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_file() { - return; - } - std::fs::create_dir_all(destination.parent().expect("插件资源父目录")) - .expect("创建插件资源目录失败"); - std::fs::copy(source, destination).expect("复制插件资源失败"); -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs deleted file mode 100644 index 04b9b6a41..000000000 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 随包阶段的原生包元数据校验,不进入运行时生产模块。 - -use super::codex_bundle::{Layout, VERSION}; - -pub fn validate_package_metadata( - metadata: &serde_json::Value, - target: &str, - layout: Layout, -) -> Result<(), String> { - if metadata["layoutVersion"] == 1 - && metadata["version"] == VERSION - && metadata["target"] == target - && metadata["entrypoint"] == layout.executable - && metadata["resourcesDir"] == "codex-resources" - && metadata["pathDir"] == "codex-path" - { - Ok(()) - } else { - Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) - } -} - -#[cfg(test)] -mod tests { - use super::super::codex_bundle::for_target; - use super::*; - - #[test] - fn metadata_rejects_version_architecture_and_layout_drift() { - let target = "aarch64-apple-darwin"; - let layout = for_target(target).unwrap(); - let valid = serde_json::json!({ - "layoutVersion": 1, - "version": VERSION, - "target": target, - "entrypoint": "bin/codex", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }); - assert!(validate_package_metadata(&valid, target, layout).is_ok()); - for (key, value) in [ - ("layoutVersion", serde_json::json!(2)), - ("version", serde_json::json!("0.0.0")), - ("target", serde_json::json!("x86_64-apple-darwin")), - ("entrypoint", serde_json::json!("bin/codex.exe")), - ("resourcesDir", serde_json::json!("../private")), - ("pathDir", serde_json::json!(null)), - ] { - let mut invalid = valid.clone(); - invalid[key] = value; - assert!( - validate_package_metadata(&invalid, target, layout).is_err(), - "{key}" - ); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index ebfe90b06..3ae75c1cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -12,6 +12,11 @@ pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; pub const CODEX: Codex = Codex { + package_metadata: PackageMetadata { + layout_version: 1, + resources_dir: "codex-resources", + path_dir: "codex-path", +}, resource_directory: "resources/codex", manifest_file_name: "manifest.json", package_metadata_file_name: "codex-package.json", @@ -70,30 +75,35 @@ pub const PLUGINS: Plugins = Plugins { subdirectories: &[ Subdirectory { path: "src", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "panels", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "skills", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "native/payload", + origin: "source", target_contains: &["windows"], targets: &[], features: &[], }, Subdirectory { path: "dotnet/publish/win-x64", + origin: "build", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], features: &["unity-editor-execute"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 21ac80bbe..56fb12ed2 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -6,6 +6,11 @@ "version": "0.155.1", "cliVersionPrefix": "codex-cli ", "manifestSchema": "genarrative-codex-sidecar.v2", + "packageMetadata": { + "layoutVersion": 1, + "resourcesDir": "codex-resources", + "pathDir": "codex-path" + }, "resourceDirectory": "resources/codex", "manifestFileName": "manifest.json", "packageMetadataFileName": "codex-package.json", @@ -83,12 +88,13 @@ "manifestFileName": "plugin.json", "targetContainsAny": ["windows", "apple-darwin"], "subdirectories": [ - { "path": "src" }, - { "path": "panels" }, - { "path": "skills" }, - { "path": "native/payload", "targetContains": ["windows"] }, + { "path": "src", "origin": "source" }, + { "path": "panels", "origin": "source" }, + { "path": "skills", "origin": "source" }, + { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, { "path": "dotnet/publish/win-x64", + "origin": "build", "targets": ["x86_64-pc-windows-msvc"], "features": ["unity-editor-execute"] } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 28ae352b6..9ce439f32 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -37,10 +37,20 @@ pub struct NoticeSource { pub preserve: bool, } +/// 上游原生包元数据里必须与声明一致的字段。 +#[derive(Debug)] +pub struct PackageMetadata { + pub layout_version: u64, + pub resources_dir: &'static str, + pub path_dir: &'static str, +} + /// 插件工作区的随包子目录及其生效条件。 #[derive(Debug)] pub struct Subdirectory { pub path: &'static str, + /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 + pub origin: &'static str, pub target_contains: &'static [&'static str], pub targets: &'static [&'static str], pub features: &'static [&'static str], @@ -58,6 +68,7 @@ pub struct LibraryStaging { #[derive(Debug)] pub struct Codex { + pub package_metadata: PackageMetadata, pub resource_directory: &'static str, pub manifest_file_name: &'static str, pub package_metadata_file_name: &'static str, @@ -236,6 +247,158 @@ pub fn declared_relative_path(relative: &str) -> PathBuf { relative.split('/').collect() } +/// 只读校验插件随包工作区。 +/// +/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录 +/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。 +pub fn validate_staged_plugins( + source_root: &Path, + destination_root: &Path, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> Result<(), String> { + if !plugin_staging_applies(target) { + return Ok(()); + } + if !destination_root.is_dir() { + return Err(format!( + "插件随包资源目录缺失:{}", + destination_root.display() + )); + } + for plugin in plugin_directories(source_root, plugins().manifest_file_name)? { + let staged = destination_root.join(&plugin.name); + let source_manifest = plugin.path.join(plugins().manifest_file_name); + let staged_manifest = staged.join(plugins().manifest_file_name); + if !staged_manifest.is_file() { + return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); + } + if sha256_file(&source_manifest).ok() != sha256_file(&staged_manifest).ok() { + return Err(format!( + "随包插件清单与源码不一致:{}", + staged_manifest.display() + )); + } + for subdirectory in plugins().subdirectories { + if !subdirectory_enabled(subdirectory, target, &feature_enabled) { + continue; + } + let relative = declared_relative_path(subdirectory.path); + let source = plugin.path.join(&relative); + let staged_directory = staged.join(&relative); + if subdirectory_is_build_derived(subdirectory) { + if source.exists() && !staged_directory.is_dir() { + return Err(format!( + "随包构建期产物缺失:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + continue; + } + if !source.is_dir() { + continue; + } + if !staged_directory.is_dir() { + return Err(format!( + "随包插件缺少必需目录:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + for relative_file in collect_sources(&source)? { + let source_file = source.join(declared_relative_path(&relative_file)); + let staged_file = staged_directory.join(declared_relative_path(&relative_file)); + if !staged_file.is_file() { + return Err(format!("随包插件缺少文件:{}", staged_file.display())); + } + if sha256_file(&source_file).ok() != sha256_file(&staged_file).ok() { + return Err(format!( + "随包插件文件与源码不一致:{}", + staged_file.display() + )); + } + } + } + } + collect_tree_files(destination_root)?; + Ok(()) +} + +/// 声明为「由准备步骤按源码派生」的子目录。 +pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "source" +} + +/// 声明为「由构建期工具链产出」的子目录。 +pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "build" +} + +/// 仓库插件目录:含清单文件的普通目录,按名字排序。 +pub fn plugin_directories( + source_root: &Path, + manifest_file_name: &str, +) -> Result, String> { + let mut plugins = Vec::new(); + let entries = std::fs::read_dir(source_root) + .map_err(|error| format!("插件工作区不可读 {}:{error}", source_root.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("插件目录项不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("插件目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() && path.join(manifest_file_name).is_file() { + plugins.push(PluginDirectory { name, path }); + } + } + plugins.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(plugins) +} + +pub struct PluginDirectory { + pub name: String, + pub path: PathBuf, +} + +/// 按声明的跳过规则收集目录下的文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_sources(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包资源源码不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包资源目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包资源目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + if !skip_directory(&name) { + stack.push((entry.path(), relative)); + } + } else if !skip_file_name(&name) { + files.insert(relative); + } + } + } + Ok(files) +} + /// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 /// /// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 @@ -681,4 +844,151 @@ mod tests { assert!(skip_file_name("runner.test.mjs")); assert!(!skip_file_name("runner.mjs")); } + + #[test] + fn plugin_subdirectories_declare_their_origin() { + let source = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_source_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + let build = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_build_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + assert_eq!(source, ["src", "panels", "skills", "native/payload"]); + assert_eq!(build, ["dotnet/publish/win-x64"]); + } + + #[test] + fn package_metadata_expectations_come_from_the_declaration() { + assert_eq!(CODEX.package_metadata.layout_version, 1); + assert_eq!(CODEX.package_metadata.resources_dir, "codex-resources"); + assert_eq!(CODEX.package_metadata.path_dir, "codex-path"); + } + + /// 源码与随包目录各写一份插件夹具:随包侧缺测试文件、带一个构建期产物目录。 + fn write_plugin_fixture(source_root: &Path, destination_root: &Path) { + let plugin_source = source_root.join("agc-demo-editor"); + fs::create_dir_all(plugin_source.join("src")).expect("create src"); + fs::write( + plugin_source.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write manifest"); + fs::write(plugin_source.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write entry"); + fs::write(plugin_source.join("src/entry.test.mjs"), "test\n").expect("write test file"); + fs::create_dir_all(plugin_source.join("native/gdextension")).expect("create gdextension"); + + let staged = destination_root.join("agc-demo-editor"); + fs::create_dir_all(staged.join("src")).expect("create staged src"); + fs::write( + staged.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write staged manifest"); + fs::write(staged.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write staged entry"); + } + + #[test] + fn staged_plugins_are_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect("valid plugin workspace"); + + let entry = destination_root.join("agc-demo-editor/src/entry.mjs"); + fs::write(&entry, "tampered\n").expect("tamper"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject digest drift"); + assert!(error.contains("不一致"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(&entry).expect("remove"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing file"); + assert!(error.contains("缺少文件"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(destination_root.join("agc-demo-editor/plugin.json")) + .expect("remove manifest"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing manifest"); + assert!(error.contains("缺少清单"), "{error}"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-unknown-linux-gnu", + |_| true, + ) + .expect("不支持的目标直接放行"); + } + + #[cfg(unix)] + #[test] + fn staged_plugins_reject_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + symlink( + "entry.mjs", + destination_root.join("agc-demo-editor/src/link.mjs"), + ) + .expect("symlink"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + } + + #[test] + fn collect_sources_applies_declared_skip_rules() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + fs::create_dir_all(root.join("target")).expect("create target"); + fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); + fs::create_dir_all(root.join("src")).expect("create src"); + fs::write(root.join("target/junk.rs"), "junk\n").expect("write junk"); + fs::write(root.join("node_modules/pkg.js"), "pkg\n").expect("write pkg"); + fs::write(root.join("src/entry.mjs"), "entry\n").expect("write entry"); + fs::write(root.join("src/entry.test.mjs"), "test\n").expect("write test"); + fs::write(root.join(".env"), "secret\n").expect("write env"); + let files = collect_sources(root).expect("collect"); + assert_eq!(files.into_iter().collect::>(), ["src/entry.mjs"]); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 918e1e6df..707e5e29a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -8,11 +8,6 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; #[path = "../../build_support/codex_bundle.rs"] pub(crate) mod codex_bundle; -// 复用构建端校验的既有单测,生产运行时只编译共享布局。 -#[cfg(test)] -#[path = "../../build_support/codex_package_metadata.rs"] -mod codex_package_metadata; - const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index dd83b82a8..5cfcd7d3f 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -28,7 +28,13 @@ const resolveTestEndpoint = async () => testEndpoint; const runTauriDev = ( argv: string[], options: Parameters[1], -) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options }); +) => + runTauriDevImpl(argv, { + prepareFrontend: async () => {}, + // 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。 + prepareResources: () => {}, + ...options, + }); async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; @@ -153,6 +159,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { prepareFrontend: async () => { order.push('frontend-ready'); }, + prepareResources: (features) => { + expect(Array.isArray(features)).toBe(true); + order.push('resources'); + }, spawnCli: () => { order.push('spawn'); return child; @@ -172,6 +182,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(order).toEqual([ 'preflight', 'frontend-ready', + 'resources', 'spawn', 'exit', 'cleanup', diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md index 62bb4160e..9fb199b27 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | ----------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-27 起实施 M1) | +| Status | completed(2026-09-27) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 72d4a340b..598b6b7f0 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -2,9 +2,9 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-26 | +| Version | 1.0 | +| Status | in-progress(2026-09-27 起实施) | +| Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | ## 目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 43d8879a5..ab546dd32 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9588,3 +9588,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 - 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 - 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 + +## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入 + +- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。 +- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。 +- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 +- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 +- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 9 passed;`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a7e6b7763..730f133a0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本 + +- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。 +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 + ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 - **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 72f9f4c53..96794f88d 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -158,6 +158,18 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 +### 4.9 M2 实况:构建期写入边界 + +入口接线后,「谁写随包资源」按**产物来源**分界(声明里的 `origin` 字段表达同一口径): + +| 来源 | 例子 | 谁写 | 时机 | +| --- | --- | --- | --- | +| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | +| `build`:只有构建过程才产出 | 插件工作区的 `dotnet/publish/win-x64`(Unity helper 发布物) | 构建脚本 | 产物生成之后、只读校验之前 | +| 外部工具链产物 | `native/gdextension`(Godot)、Cocos payload | 构建脚本 | 同上(本里程碑不动,归位属 M3) | + +因此本里程碑后:Codex 与插件工作区的源码派生内容不再由构建脚本写入(macOS 上已实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒全程 fresh);Windows 上仍有三处构建期写入落在 `resources/plugins/**`(Unity publish 目录、Godot gdextension、Cocos payload),Windows 的构建新鲜度要等 M3 把这三处也归位到准备步骤(准备步骤先跑各自的构建命令,再复制产物)才达标。 + ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ca2b87150..df0b3b975 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,6 +88,8 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,`tauri build --no-bundle` 不强制 staging。构建脚本不再生成这些资源(只对既有产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验),因此源码不变时 `cargo build` 稳定 fresh。`resources/plugins` 由准备步骤拥有:不要手工往它下面放东西,准备步骤会按仓库 `plugins/` 与声明重建;**编辑器分支产物**(Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos payload)目前仍由构建脚本在产物生成后写入,归位属后续里程碑。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From 33308a93e84fe7271d16f1085fe53d75609f1ba0 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:57:54 +0800 Subject: [PATCH 13/60] =?UTF-8?q?=E6=A0=A1=E6=AD=A3=20M2=20=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E5=90=8E=E7=9A=84=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术方案修正症状层补丁清单:prune_staging/STAGED_* 只存在于未合入的补丁,主线从未有过 - 技术方案 §5.2/§5.3 记录过渡期已完成、.taurignore staging 条目保留到 M3 的原因,§8 M2 行标注已交付与 Windows 待复验 - M2 里程碑规范按证据勾选 macOS 侧可达的验收项,未验证项保持未勾选 - M1 实施计划标注 codex_package_metadata.rs 已在 M2 退役 --- ...�实施计划】AGC随包资源改由校验器读入-2026-09-26.md | 2 +- ...¨‹碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md | 10 +++++----- .../【技术方案】AGC随包资源staging归位-2026-09-26.md | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md index 8d664dba5..a200761ec 100644 --- a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -59,6 +59,6 @@ 准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): - 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); -- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。 +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。) 理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 598b6b7f0..dfe1a529b 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -34,11 +34,11 @@ ## 验收标准 -- [ ] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。 -- [ ] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。 -- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。 -- [ ] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。 -- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。 +- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) +- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 9 条用例含幂等与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) ## 证据要求 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 96794f88d..dcdbb6af8 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -17,7 +17,7 @@ 2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 -5. 删除症状层补丁(整目录重建的规避、`prune_staging`、`STAGED_*` 幂等判断、`.taurignore` 的 staging 条目)。 +5. 删除症状层补丁(整目录重建的规避、为绕开自触发而加的 `.taurignore` staging 条目)。`prune_staging`、`STAGED_*` 一类命名只出现在未合入的症状层补丁里,主线从未有过,不需要清理。 ### 2.2 非目标 @@ -114,7 +114,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 删除: -1. 五条 staging 的写入逻辑、`prune_staging`、`STAGED_*` 幂等判断、针对大目录的整目录删除。 +1. 五条 staging 的写入逻辑(M2 删除了 codex 与插件工作区的写入分支,仍是构建期产物的三处留在 M3)、针对大目录的整目录重建。 2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 ### 4.5 资源清单(谁生成、谁消费) @@ -173,8 +173,8 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 -2. **过渡期**:改造期间 `resources/**` 仍由 build script 生成(当前主线状态),准备步骤上线后先并存(生成结果与校验一致),再删除 build script 的写入分支——删除与新增不得跨里程碑混在一起。 -3. **本机残留**:改造后 `.taurignore` 的 staging 条目、`prune_staging` 及相关注释一并删除;`resources/**` 仍保持 gitignored。 +2. **过渡期(已完成)**:M1 让准备步骤与构建脚本产物并存并逐文件比对一致,M2 移除了构建脚本里 codex 与插件工作区的写入分支;剩余在构建期写入的三处(Unity publish 目录、Godot gdextension、Cocos payload)随 M3 归位。删除与新增不跨里程碑混在一起。 +3. **本机残留**:M2 已删除 codex 与插件工作区的写入逻辑与整目录重建;`.taurignore` 的 staging 条目及其原因说明保留到 M3——Windows 上仍有三处构建期写入落在 `resources/plugins/**`,去掉忽略会重新引入监听自触发。`resources/**` 仍保持 gitignored。 4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 ## 6. 验收标准与证据 @@ -208,8 +208,8 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | 里程碑 | 交付 | 停止条件 | |---|---|---| | M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | -| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿(Windows 真机验收) | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 From 96b44d86602541d2e9cd0f17eb6e4cc402e636e9 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 19:01:00 +0800 Subject: [PATCH 14/60] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E6=BC=82=E7=A7=BB=E7=9A=84=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 准备步骤用例新增一条:上游 codex-package.json 的 version/layoutVersion/entrypoint/resourcesDir 任一与声明不一致时必须拒绝且不落产物(共 10 条) - 同步决策日志与 M2 里程碑规范里的用例计数 --- .../prepare-bundled-resources.test.mjs | 43 +++++++++++++++++++ ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 2 +- .../shared-memory/decision-log.md | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index b3cf4479b..ef3425e47 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -312,6 +312,49 @@ test('copies only whitelisted plugin subdirectories', () => { } }); +test('fails closed when the upstream package metadata drifts from the declaration', () => { + const fixture = buildFixture(); + try { + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const metadataFile = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`, + ); + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8')); + assert.equal(metadata.version, declaration.codex.version); + for (const [key, value] of [ + ['version', '0.0.0'], + ['layoutVersion', 2], + ['entrypoint', 'bin/other.exe'], + ['resourcesDir', '../private'], + ]) { + fs.writeFileSync( + metadataFile, + `${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`, + ); + assert.throws( + () => prepare(fixture), + /上游包元数据与声明不一致/, + `${key} 漂移必须被拒绝`, + ); + } + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '拒绝时不得留下产物', + ); + } finally { + fixture.cleanup(); + } +}); + test('fails closed when the upstream package is missing', () => { const fixture = buildFixture(); try { diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index dfe1a529b..9486a877f 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -37,7 +37,7 @@ - [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) - [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) - [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) -- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 9 条用例含幂等与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) - [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ab546dd32..bc5846ba7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9596,5 +9596,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 - 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 - 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 -- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 9 passed;`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 - 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 From 0b19df467e4a52d1b379774a8da779b634057352 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 23:01:20 +0800 Subject: [PATCH 15/60] =?UTF-8?q?=E8=A1=A5=E5=85=85=20M3=20=E7=9A=84=20Win?= =?UTF-8?q?dows=20=E4=BE=A7=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑规范新增「验证步骤(Windows 侧执行清单)」:准备步骤单跑与幂等、构建期不再写随包资源、构建新鲜度、打包一致性、客户端启动与三类边界负例 - 明确记录方式:命令与输出贴回里程碑或 PR,未通过项回到主规范 §9 --- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 8fcc64b1e..712fff2fa 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -37,6 +37,26 @@ - [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 - [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 +## 验证步骤(Windows 侧执行清单) + +准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 + +1. **准备步骤单独跑(不打包)** + - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展、`agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 全部在位。 + - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 +2. **构建期不再写随包资源** + - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 +3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本与只读校验)必须通过。 +5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 +6. **边界(负例)** + - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 + - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 + - `AGC_SKIP_RESOURCE_STAGING=1 cargo build`:只做只读校验;手工改 `resources/**` 一个字节即应失败。 + +记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 + ## 证据要求 - 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 From faa510e99975ae9b81a2df1f35e78da6b1082b36 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 23:33:48 +0800 Subject: [PATCH 16/60] =?UTF-8?q?AGC=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=88=86=E6=94=AF=E4=BA=A7=E7=89=A9=E5=BD=92=E4=BD=8D=EF=BC=9A?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E5=BD=BB=E5=BA=95=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 声明扩展:subdirectories 新增 origin=prepared;libraryStaging 增加 prepare/files;新增 nativePayloads 与 plugins.prepareSteps(程序类型、工作目录、指纹、必需产物) - Godot 随包文件清单改由声明提供,godot_bundle::BUNDLE_FILES 从生成的编译期常量取值,不再各写一份 - 准备步骤按声明运行 powershell.exe -File build.ps1(Unity/Godot,Godot 移除 PSModulePath)与 cargo build -p cocos-editor-bridge --target … --features windows-injection,内容指纹命中且必需产物齐全时零写入,命令执行器可注入以便在 macOS 上覆盖调度与失败关闭 - build.rs 删除 prepare_unity_editor_helper、prepare_godot_editor_extension、stage_cocos_editor_payload、stage_build_generated_plugin_payloads 及辅助函数(415 → 193 行),只留只读校验,并新增已准备产物存在性与 Godot 随包库深度校验;AGC_SKIP_RESOURCE_STAGING 开关随写入分支删除 - 删除两份只含 staging 条目的 .taurignore;发布入口传 profile=release 以定位 Cocos 产物 - 文档:技术方案 §4.9/§8、M3 里程碑(含 Windows 验收清单)、运维文档、决策日志与排障经验同步 - 验证:准备步骤 13 条用例通过(三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等);npm run agc:bundled-resources:check 通过;cargo check --no-default-features 通过且第二次 0.62 秒 fresh - 未验证:Windows 真机(powershell/cargo 路径、产物归位、包内容一致性与客户端加载),按 M3 里程碑验收清单在 Windows 上确认 --- apps/ai-game-creator-shell/.taurignore | 4 - .../scripts/build-release.mjs | 1 + .../scripts/check-package-layout.mjs | 18 +- .../scripts/prepare-bundled-resources.mjs | 352 +++++++++++++++++- .../prepare-bundled-resources.test.mjs | 126 +++++++ .../src-tauri/.taurignore | 4 - apps/ai-game-creator-shell/src-tauri/build.rs | 344 +++-------------- .../src-tauri/build_support/godot_bundle.rs | 12 +- .../build_support/package-layout.generated.rs | 4 +- .../build_support/package-layout.json | 67 +++- .../src-tauri/build_support/package_layout.rs | 14 +- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 19 +- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 8 +- ...术方案】AGC随包资源staging归位-2026-09-26.md | 13 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 16 files changed, 664 insertions(+), 336 deletions(-) delete mode 100644 apps/ai-game-creator-shell/.taurignore delete mode 100644 apps/ai-game-creator-shell/src-tauri/.taurignore diff --git a/apps/ai-game-creator-shell/.taurignore b/apps/ai-game-creator-shell/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e6ddb30cd..a10f95ee8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -440,6 +440,7 @@ export function stageBundledResources( const summaries = prepare({ target, features: new Set(defaultEditorFeatures(target)), + profile: 'release', log: (line) => console.log(`[ai-game-creator-shell] ${line}`), }); for (const summary of summaries) { diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 023f19ae4..617257d34 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -81,11 +81,11 @@ function expectInteger(value, at) { return value; } -// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责), -// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。 +// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制), +// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。 function expectOrigin(value, at) { - if (value !== 'source' && value !== 'build') { - fail(`${at} 必须是 source 或 build(实际 ${String(value)})`); + if (value !== 'source' && value !== 'prepared') { + fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`); } return value; } @@ -227,15 +227,21 @@ function parseDeclaration(raw) { ).map((entry, index) => { const at = `plugins.libraryStaging[${index}]`; const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } return { plugin: expectString(parsed.plugin, `${at}.plugin`), sourceSubdirectory: expectString( parsed.sourceSubdirectory, `${at}.sourceSubdirectory`, ), + prepare: expectString(parsed.prepare, `${at}.prepare`), targets: expectStringArray(parsed.targets, `${at}.targets`), features: expectStringArray(parsed.features, `${at}.features`), layout: expectString(parsed.layout, `${at}.layout`), + files, }; }); @@ -445,6 +451,10 @@ pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings( + declaration.plugins.libraryStaging[0]?.files ?? [], + )}; + pub const CODEX: Codex = ${codexStruct}; pub const PLUGINS: Plugins = ${pluginsStruct}; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index b5538138f..577e638c1 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -11,6 +11,7 @@ // 用法(在 apps/ai-game-creator-shell 下): // node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] +import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, @@ -700,9 +701,6 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if (subdirectory.origin !== 'source') { - continue; - } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -743,13 +741,330 @@ function assertOwnedPluginRoot(destination, plugins) { } } +/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。 +function prepareStepsReferencedBy(declaration, target, features) { + const required = new Set(); + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin === 'prepared' && + subdirectoryEnabled(subdirectory, target, features) + ) { + required.add(subdirectory.prepare); + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if (libraryStagingEnabled(staging, target, features)) { + required.add(staging.prepare); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (nativePayloadEnabled(payload, target, features)) { + required.add(payload.prepare); + } + } + return required; +} + +function libraryStagingEnabled(staging, target, features) { + return ( + (!staging.targets?.length || staging.targets.includes(target)) && + (staging.features ?? []).every((name) => features.has(name)) + ); +} + +function nativePayloadEnabled(payload, target, features) { + return ( + (!payload.targets?.length || payload.targets.includes(target)) && + (payload.features ?? []).every((name) => features.has(name)) + ); +} + +/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。 +function fingerprintSources(repoRoot, step) { + const { roots, excludeDirectoryNames } = step.fingerprint; + const lines = []; + for (const root of roots) { + const absoluteRoot = path.join(repoRoot, step.workingDirectory, root); + for (const relative of collectFingerprintFiles( + absoluteRoot, + excludeDirectoryNames, + )) { + const file = path.join(absoluteRoot, relative); + const info = statSync(file); + lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`); + } + } + return sha256Text(lines.join('\n')); +} + +function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') { + const files = []; + if (!existsSync(root)) { + return files; + } + for (const entry of readdirSync(root, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`); + } + if (entry.isDirectory()) { + if (!excludeDirectoryNames.includes(entry.name)) { + files.push( + ...collectFingerprintFiles( + path.join(root, entry.name), + excludeDirectoryNames, + relative, + ), + ); + } + } else if (entry.isFile()) { + files.push(relative); + } + } + return files.sort(); +} + +function requiredOutputsPresent(repoRoot, step) { + return step.requiredOutputs.every((relative) => { + const file = path.join(repoRoot, relative); + return ( + existsSync(file) && statSync(file).isFile() && statSync(file).size > 0 + ); + }); +} + +function defaultRunCommand({ program, args, cwd, removeEnvironment }) { + const environment = { ...process.env }; + for (const name of removeEnvironment ?? []) { + delete environment[name]; + } + const result = spawnSync(program, args, { + cwd, + env: environment, + stdio: 'inherit', + }); + if (result.error) { + throw new PrepareError(`执行 ${program} 失败:${result.error.message}`); + } + if (result.status !== 0) { + throw new PrepareError(`${program} 退出码 ${result.status}`); + } +} + +/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。 +export function ensurePreparedArtifacts({ + declaration, + repoRoot, + target, + features, + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, +}) { + const required = prepareStepsReferencedBy(declaration, target, features); + const steps = new Map( + (declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]), + ); + for (const name of required) { + const step = steps.get(name); + if (!step) { + fail(`声明引用了未定义的准备步骤:${name}`); + } + const stampPath = step.fingerprint + ? path.join( + repoRoot, + step.workingDirectory, + step.fingerprint.stampRelativePath, + ) + : undefined; + if (step.fingerprint && step.requiredOutputs.length > 0) { + const expected = fingerprintSources(repoRoot, step); + const current = existsSync(stampPath) + ? readFileSync(stampPath, 'utf8').trim() + : ''; + if (current === expected && requiredOutputsPresent(repoRoot, step)) { + log(`${name} 命中缓存(指纹一致)`); + continue; + } + } + if (dryRun) { + log(`${name} 需要重新构建(dry-run 未执行)`); + continue; + } + if (step.kind === 'powershell') { + runCommand({ + program: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repoRoot, step.workingDirectory, step.scriptFileName), + ], + cwd: path.join(repoRoot, step.workingDirectory), + removeEnvironment: step.removeEnvironment ?? [], + }); + } else if (step.kind === 'cargo') { + runCommand({ + program: 'cargo', + args: [ + 'build', + '--manifest-path', + path.join( + repoRoot, + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ), + '-p', + path.basename(step.packageDirectory), + '--target', + target, + ...(profile === 'release' ? ['--release'] : []), + ...(step.features?.length + ? ['--features', step.features.join(',')] + : []), + ], + cwd: repoRoot, + removeEnvironment: [], + }); + } else { + fail(`未实现的准备步骤类型:${step.kind}`); + } + if (!requiredOutputsPresent(repoRoot, step)) { + const missing = step.requiredOutputs.filter( + (relative) => !existsSync(path.join(repoRoot, relative)), + ); + fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`); + } + if (stampPath && step.fingerprint) { + mkdirSync(path.dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`); + } + log(`${name} 已构建`); + } +} + +/// 复制声明为已准备的随包子目录与随包库。 +function copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, +}) { + for (const plugin of pluginDirectories(declaration, repoRoot)) { + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin !== 'prepared' || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(staging, plugin.name, subdirectory.path), + ); + } + for (const stagingEntry of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== stagingEntry.plugin || + !libraryStagingEnabled(stagingEntry, target, features) + ) { + continue; + } + for (const relative of stagingEntry.files) { + copyFilePreservingMode( + path.join(plugin.root, stagingEntry.sourceSubdirectory, relative), + path.join( + staging, + plugin.name, + stagingEntry.sourceSubdirectory, + relative, + ), + ); + } + } + } +} + +/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 +function copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot, + staging, + target, + features, + profile, +}) { + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (!nativePayloadEnabled(payload, target, features)) { + continue; + } + const candidates = [ + path.join( + srcTauriRoot, + 'target', + target, + profile, + 'deps', + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + target, + profile, + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + profile, + 'deps', + payload.sourceFileName, + ), + path.join(srcTauriRoot, 'target', profile, payload.sourceFileName), + ]; + const source = candidates.find((candidate) => existsSync(candidate)); + if (!source) { + fail( + `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, + ); + } + copyFilePreservingMode( + source, + path.join( + repoRoot, + 'plugins', + payload.plugin, + payload.destinationSubdirectory, + payload.sourceFileName, + ), + ); + copyFilePreservingMode( + source, + path.join( + staging, + payload.plugin, + payload.destinationSubdirectory, + payload.sourceFileName, + ), + ); + } +} + function preparePlugins({ declaration, target, destinationRoot, + repoRoot, features, plugins, record, + profile, dryRun, }) { const destination = path.join( @@ -799,6 +1114,22 @@ function preparePlugins({ ); } } + copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, + }); + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging, + target, + features, + profile, + }); }); return { summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, @@ -814,6 +1145,9 @@ export function prepareBundledResources({ features = new Set(defaultEditorFeatures(target)), recordPath = RECORD_PATH, lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, dryRun = false, repoRoot = REPO_ROOT, appRoot = path.dirname(destinationRoot), @@ -844,13 +1178,25 @@ export function prepareBundledResources({ target.includes(needle), ) ) { + ensurePreparedArtifacts({ + declaration, + repoRoot, + target, + features, + profile, + runCommand, + dryRun, + log, + }); const plugins = preparePlugins({ declaration, target, destinationRoot, + repoRoot, features, plugins: pluginDirectories(declaration, repoRoot), record, + profile, dryRun, }); summaries.push(plugins.summary); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index ef3425e47..19b90da94 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -23,6 +23,33 @@ function sha256File(file) { } /// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。 +function writePrepareArtifacts(root, declaration) { + const created = []; + for (const step of declaration.plugins.prepareSteps ?? []) { + for (const relative of step.requiredOutputs) { + const file = path.join(root, relative); + if (fs.existsSync(file)) { + continue; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prepared ${relative}\n`); + created.push(relative); + } + } + return created; +} + +/// 记录调用的假执行器:默认把声明的必需产物造出来。 +function fakeRunCommand({ created = [], failOn = null } = {}) { + return (invocation) => { + created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`); + if (failOn && invocation.program === failOn) { + throw new Error('fake run failure'); + } + }; +} + function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); const appRoot = path.join(root, 'app'); @@ -114,6 +141,18 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); } + writePrepareArtifacts(repoRoot, declaration); + const cocosDll = path.join( + destinationRoot, + 'target', + WINDOWS_TARGET, + 'debug', + 'deps', + 'cocos-editor-bridge.dll', + ); + fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); + fs.writeFileSync(cocosDll, 'cocos bridge payload\n'); + const lockfilePath = path.join(root, 'package-lock.json'); fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); return { @@ -156,6 +195,7 @@ function snapshot(directory) { } function prepare(fixture, overrides = {}) { + const runner = overrides.runCommand ?? fakeRunCommand(); return prepareBundledResources({ target: WINDOWS_TARGET, destinationRoot: fixture.destinationRoot, @@ -164,6 +204,7 @@ function prepare(fixture, overrides = {}) { lockfilePath: fixture.lockfilePath, repoRoot: fixture.repoRoot, appRoot: fixture.appRoot, + runCommand: runner, ...overrides, }); } @@ -458,3 +499,88 @@ test('declaration drives source lookup and staging units', () => { fixture.cleanup(); } }); + +test('runs declared prepare steps once and copies their artifacts into the staged tree', () => { + const fixture = buildFixture(); + try { + const created = []; + const summaries = prepare(fixture, { + runCommand: fakeRunCommand({ created }), + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + assert.match(summaries[0], /命中缓存|重新生成/); + assert.ok( + created.some( + (entry) => + entry.startsWith('powershell.exe') && entry.includes('build.ps1'), + ), + '必须执行声明的 powershell 准备步骤', + ); + assert.ok( + created.some( + (entry) => + entry.startsWith('cargo build') && entry.includes('--target'), + ), + '必须执行声明的 cargo 准备步骤', + ); + const stagedPayload = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ); + assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录'); + assert.ok( + fs.existsSync( + path.join( + fixture.repoRoot, + 'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ), + ), + 'native payload 必须写回插件工作区(唯一真源)', + ); + } finally { + fixture.cleanup(); + } +}); + +test('skips prepare steps whose fingerprint is unchanged', () => { + const fixture = buildFixture(); + try { + const first = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: first }) }); + assert.ok(first.length > 0, '首次必须执行准备步骤'); + const second = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: second }) }); + const unityRuns = second.filter( + (entry) => + entry.startsWith('powershell.exe') && + entry.includes('agc-unity-editor'), + ); + assert.deepEqual( + unityRuns, + [], + '指纹一致时不应再跑声明了指纹的 Unity 准备步骤', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when a prepare step does not produce its declared outputs', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), { + recursive: true, + force: true, + }); + assert.throws( + () => prepare(fixture, { runCommand: fakeRunCommand() }), + /未产出必需文件/, + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/.taurignore b/apps/ai-game-creator-shell/src-tauri/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/src-tauri/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 948438b62..3138d2279 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -10,7 +10,6 @@ mod godot_bundle; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; @@ -76,9 +75,65 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) { ); } validate_staged_plugin_workspace(manifest_dir, &target); + validate_prepared_payloads(manifest_dir, &target); } /// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 +/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。 +fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + let destination_root = manifest_dir.join(declared.destination_directory); + for plugin in package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")) + { + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + let staged = destination_root.join(&plugin.name).join(&relative); + if !staged.is_dir() { + panic!( + "随包已准备产物缺失:{}(请先执行随包资源准备步骤)", + staged.display() + ); + } + } + for staging in declared.library_staging { + if plugin.name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + let staged = destination_root.join(&plugin.name).join(&relative); + godot_bundle::validate(&staged) + .unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}")); + } + } +} + fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { let declared = package_layout::plugins(); let repo_root = manifest_dir @@ -104,14 +159,6 @@ fn main() { "cargo:rustc-env=AGC_BUILD_TARGET={}", env::var("TARGET").expect("Cargo TARGET") ); - // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), - // 用于在既有产物上单独验证校验路径。 - if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_build_generated_plugin_payloads(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); - } validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); @@ -134,282 +181,3 @@ fn main() { } tauri_build::build() } - -#[cfg(windows)] -fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { - if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() { - return; - } - let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); - let profile_dir = out_dir - .ancestors() - .find(|path| path.file_name().is_some_and(|name| name == "build")) - .and_then(|build_dir| build_dir.parent()) - .expect("AGC Cargo profile directory not found"); - let candidates = [ - profile_dir.join("deps/cocos_editor_bridge.dll"), - profile_dir.join("cocos_editor_bridge.dll"), - ]; - let source = candidates - .iter() - .find(|path| path.is_file()) - .unwrap_or_else(|| { - panic!( - "Cocos bridge native payload 未构建:{}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(";") - ) - }); - for destination in [ - // 插件工作区里的 payload 是开发态与打包态的唯一真源。 - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。 - manifest_dir - .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - ] { - std::fs::create_dir_all(destination.parent().expect("payload resource parent")) - .expect("创建 Cocos bridge payload 目录失败"); - std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); - } - println!("cargo:rerun-if-changed={}", source.display()); -} - -#[cfg(not(windows))] -fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} - -/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 -fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); - let target = env::var("TARGET").expect("Cargo TARGET"); - if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() - || target != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); - let mut sources = Vec::new(); - collect_unity_helper_sources(&root, &mut sources); - sources.sort(); - let mut fingerprint = Sha256::new(); - for source in &sources { - println!("cargo:rerun-if-changed={}", source.display()); - fingerprint.update( - source - .strip_prefix(&root) - .expect("helper source") - .to_string_lossy() - .as_bytes(), - ); - fingerprint.update([0]); - fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); - } - let fingerprint = format!("{:x}", fingerprint.finalize()); - let publish = root.join("publish/win-x64"); - let executable = publish.join("Agc.Unity.Attach.exe"); - let stamp = publish.join(".agc-source.sha256"); - println!("cargo:rerun-if-changed={}", executable.display()); - if unity_helper_publish_complete(&publish) - && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) - { - return; - } - assert!( - cfg!(windows), - "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" - ); - let status = std::process::Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Unity helper 构建脚本"); - assert!( - status.success() && unity_helper_publish_complete(&publish), - "Unity helper 构建失败或缺少运行文件/许可" - ); - fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); -} - -fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { - [ - "Agc.Unity.Attach.exe", - "NOTICE", - "THIRD-PARTY-NOTICES.txt", - "licenses/DotCraft-Apache-2.0.txt", - "licenses/Roslyn-MIT.txt", - "licenses/upstream.json", - "licenses/dotnet-LICENSE.TXT", - "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", - "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", - "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", - ] - .iter() - .all(|name| { - fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { - metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 - }) - }) -} - -fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { - for entry in fs::read_dir(root) - .expect("Unity helper 源码目录缺失") - .flatten() - { - let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); - assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); - let name = entry.file_name(); - if kind.is_dir() { - if !matches!( - name.to_str(), - Some("bin" | "obj" | "publish" | "native-build") - ) { - collect_unity_helper_sources(&entry.path(), sources); - } - } else if kind.is_file() { - sources.push(entry.path()); - } - } -} - -fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE"); - if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none() - || env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension"); - for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) { - println!("cargo:rerun-if-changed={}", source.display()); - } - assert!( - cfg!(windows), - "构建 Godot 原生扩展需要 Windows x64 C 编译器" - ); - let status = std::process::Command::new("powershell.exe") - // Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。 - .env_remove("PSModulePath") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Godot 原生扩展构建脚本"); - assert!(status.success(), "Godot 原生扩展构建失败"); - godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); -} - -/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。 -/// -/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。 -/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。 -fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - if !package_layout::plugin_staging_applies(&target) { - return; - } - let declared = package_layout::plugins(); - let repo_root = manifest_dir - .parent() - .and_then(|app_root| app_root.parent()) - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下"); - let destination_root = manifest_dir.join(declared.destination_directory); - let plugins = package_layout::plugin_directories( - &repo_root.join(declared.source_directory), - declared.manifest_file_name, - ) - .unwrap_or_else(|error| panic!("{error}")); - for plugin in plugins { - for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_is_build_derived(subdirectory) - || !package_layout::subdirectory_enabled( - subdirectory, - &target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(subdirectory.path); - let source = plugin.path.join(&relative); - if !source.is_dir() { - continue; - } - copy_staged_tree( - &source, - &destination_root.join(&plugin.name).join(&relative), - ); - } - for staging in declared.library_staging { - if plugin.name != staging.plugin - || !package_layout::library_staging_enabled( - staging, - &target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(staging.source_subdirectory); - match staging.layout { - "godot-bundle" => godot_bundle::stage( - &plugin.path.join(&relative), - &destination_root.join(&plugin.name).join(&relative), - &target, - true, - ) - .unwrap_or_else(|error| panic!("{error}")), - other => panic!("未实现的随包库 staging 布局:{other}"), - } - } - } -} - -/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。 -fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_dir() { - return; - } - fs::create_dir_all(destination).expect("创建插件资源目录失败"); - for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() { - let path = entry.path(); - let file_name = entry.file_name(); - let name = file_name.to_string_lossy().to_string(); - let target = destination.join(&file_name); - assert!( - entry - .file_type() - .expect("读取插件文件类型失败") - .is_symlink(), - "插件资源不允许符号链接" - ); - if path.is_dir() { - if !package_layout::skip_directory(&name) { - copy_staged_tree(&path, &target); - } - } else if !package_layout::skip_file_name(&name) { - let bytes = fs::read(&path).expect("读取插件资源失败"); - if fs::read(&target).is_ok_and(|existing| existing == bytes) { - continue; - } - fs::write(&target, bytes).expect("复制插件资源失败"); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 8a326ac98..616ad5b30 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -2,12 +2,12 @@ use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; -pub const BUNDLE_FILES: [&str; 4] = [ - "bin/win-x64/agc_godot_editor.dll", - "bin/win-x64/metadata.json", - "vendor/LICENSE.txt", - "vendor/provenance.json", -]; +// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +mod package_layout; + +pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES; fn plain_metadata(path: &Path) -> Result { let metadata = fs::symlink_metadata(path) diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 3ae75c1cb..88247e41d 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -11,6 +11,8 @@ pub const CODEX_VERSION: &str = "0.155.1"; pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; +pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"]; + pub const CODEX: Codex = Codex { package_metadata: PackageMetadata { layout_version: 1, @@ -103,7 +105,7 @@ Subdirectory { }, Subdirectory { path: "dotnet/publish/win-x64", - origin: "build", + origin: "prepared", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], features: &["unity-editor-execute"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 56fb12ed2..8164358ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -94,7 +94,8 @@ { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, { "path": "dotnet/publish/win-x64", - "origin": "build", + "origin": "prepared", + "prepare": "unity-helper-publish", "targets": ["x86_64-pc-windows-msvc"], "features": ["unity-editor-execute"] } @@ -103,9 +104,71 @@ { "plugin": "agc-godot-editor", "sourceSubdirectory": "native/gdextension", + "prepare": "godot-extension-build", "targets": ["x86_64-pc-windows-msvc"], "features": ["godot-editor-execute"], - "layout": "godot-bundle" + "layout": "godot-bundle", + "files": [ + "bin/win-x64/agc_godot_editor.dll", + "bin/win-x64/metadata.json", + "vendor/LICENSE.txt", + "vendor/provenance.json" + ] + } + ], + "nativePayloads": [ + { + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "sourceFileName": "cocos-editor-bridge.dll", + "destinationSubdirectory": "native/payload", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["cocos-editor-injection"] + } + ], + "prepareSteps": [ + { + "name": "unity-helper-publish", + "kind": "powershell", + "workingDirectory": "plugins/agc-unity-editor/dotnet", + "scriptFileName": "build.ps1", + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "stampRelativePath": "publish/win-x64/.agc-source.sha256" + }, + "requiredOutputs": [ + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe", + "plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE", + "plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf" + ] + }, + { + "name": "godot-extension-build", + "kind": "powershell", + "workingDirectory": "plugins/agc-godot-editor/native/gdextension", + "scriptFileName": "build.ps1", + "removeEnvironment": ["PSModulePath"], + "requiredOutputs": [ + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", + "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", + "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" + ] + }, + { + "name": "cocos-bridge-build", + "kind": "cargo", + "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", + "features": ["windows-injection"], + "requiredOutputs": [] } ], "skipDirectoryNames": ["target", "node_modules"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 9ce439f32..e27c8bedd 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -286,7 +286,7 @@ pub fn validate_staged_plugins( let relative = declared_relative_path(subdirectory.path); let source = plugin.path.join(&relative); let staged_directory = staged.join(&relative); - if subdirectory_is_build_derived(subdirectory) { + if subdirectory_is_prepared(subdirectory) { if source.exists() && !staged_directory.is_dir() { return Err(format!( "随包构建期产物缺失:{}(插件 {})", @@ -330,9 +330,9 @@ pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { subdirectory.origin == "source" } -/// 声明为「由构建期工具链产出」的子目录。 -pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool { - subdirectory.origin == "build" +/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。 +pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "prepared" } /// 仓库插件目录:含清单文件的普通目录,按名字排序。 @@ -853,14 +853,14 @@ mod tests { .filter(|entry| subdirectory_is_source_derived(entry)) .map(|entry| entry.path) .collect::>(); - let build = PLUGINS + let prepared = PLUGINS .subdirectories .iter() - .filter(|entry| subdirectory_is_build_derived(entry)) + .filter(|entry| subdirectory_is_prepared(entry)) .map(|entry| entry.path) .collect::>(); assert_eq!(source, ["src", "panels", "skills", "native/payload"]); - assert_eq!(build, ["dotnet/publish/win-x64"]); + assert_eq!(prepared, ["dotnet/publish/win-x64"]); } #[test] diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 712fff2fa..d9be7289d 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -2,11 +2,19 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-26 | +| Version | 1.0 | +| Status | in-progress(2026-09-27 实现完成,待 Windows 真机验收) | +| Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | +## 已实现(2026-09-27) + +- 声明新增三类「准备步骤」产物:`subdirectories[origin=prepared]`(Unity publish 目录)、`libraryStaging[].prepare + files`(Godot gdextension)、`nativePayloads[]`(Cocos bridge dll);`plugins.prepareSteps` 描述每个准备步骤的程序、工作目录、指纹与必需产物。 +- 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 +- 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 +- `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 +- 准备步骤的 Windows 侧行为**尚未在真机验证**:本机 macOS 只能跑通编译、声明门禁与用例(命令执行器在用例中注入假实现)。 + ## 目标 编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 @@ -48,12 +56,13 @@ 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 -4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本与只读校验)必须通过。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本的只读校验)必须通过。 5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 6. **边界(负例)** - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 - - `AGC_SKIP_RESOURCE_STAGING=1 cargo build`:只做只读校验;手工改 `resources/**` 一个字节即应失败。 + - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(源码派生内容逐文件比对)。 记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc5846ba7..707659ae2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9598,3 +9598,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 - 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 - 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 + +## 2026-09-27 AGC 编辑器分支产物归位:构建脚本彻底退出写入 + +- 背景:M2 之后构建脚本仍生成 Unity publish 目录、Godot gdextension 与 Cocos bridge payload,这三处写入落在 `resources/plugins/**`(`bundle.resources` 映射目录),Windows 上仍会触发每次重编,`.taurignore` 的 staging 条目也还不能删。 +- 决策(声明扩展):`subdirectories` 新增 `origin: prepared`;`libraryStaging` 增加 `prepare` 与 `files`;新增 `nativePayloads` 与 `plugins.prepareSteps`(程序类型、工作目录、指纹、必需产物)。Godot 随包文件清单改由声明提供——`godot_bundle::BUNDLE_FILES` 从生成的编译期常量取值,不再各写一份。 +- 决策(准备步骤执行器):准备步骤按声明运行 `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1`(Unity/Godot,Godot 额外移除 `PSModulePath`)与 `cargo build -p cocos-editor-bridge --target … --features windows-injection`;内容指纹命中且必需产物齐全时零写入;命令执行器可注入,便于在 macOS 上用假执行器覆盖调度、指纹与失败关闭逻辑。 +- 决策(构建脚本瘦身):删除 `prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数(build.rs 415 → 193 行),只留只读校验,并新增「已准备产物存在性 + Godot 随包库深度校验」;`AGC_SKIP_RESOURCE_STAGING` 开关随写入分支一并删除;两份只含 staging 条目的 `.taurignore` 删除。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 +- 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 +- 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 730f133a0..9a319d877 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,11 +12,11 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 -## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本 +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 -- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。 -- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。 -- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`prepared` 只查存在性,Godot 随包库额外跑 `godot_bundle::validate` 的深度校验。 +- **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index dcdbb6af8..245d7cfce 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -158,17 +158,18 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 -### 4.9 M2 实况:构建期写入边界 +### 4.9 M2/M3 实况:构建期写入边界 -入口接线后,「谁写随包资源」按**产物来源**分界(声明里的 `origin` 字段表达同一口径): +「谁写随包资源」按产物来源分界,声明里的 `origin` 字段表达同一口径: | 来源 | 例子 | 谁写 | 时机 | | --- | --- | --- | --- | | `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | -| `build`:只有构建过程才产出 | 插件工作区的 `dotnet/publish/win-x64`(Unity helper 发布物) | 构建脚本 | 产物生成之后、只读校验之前 | -| 外部工具链产物 | `native/gdextension`(Godot)、Cocos payload | 构建脚本 | 同上(本里程碑不动,归位属 M3) | +| `prepared` / `libraryStaging` / `nativePayloads`:需要外部工具链或同一次 cargo 构建 | Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos `native/payload` | 准备步骤:先按声明运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …`,再复制产物 | 同上 | -因此本里程碑后:Codex 与插件工作区的源码派生内容不再由构建脚本写入(macOS 上已实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒全程 fresh);Windows 上仍有三处构建期写入落在 `resources/plugins/**`(Unity publish 目录、Godot gdextension、Cocos payload),Windows 的构建新鲜度要等 M3 把这三处也归位到准备步骤(准备步骤先跑各自的构建命令,再复制产物)才达标。 +M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随包资源(连编辑器分支产物一并交给准备步骤),并在校验阶段确认源码派生内容逐文件一致、已准备产物在位、Godot 随包库通过既有深度校验。因此源码不变时 `cargo` 稳定 fresh(macOS 实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒),`resources/plugins` 也不再需要在 `.taurignore` 里忽略(两份 staging 条目已删除)。 + +准备步骤的 Windows 侧命令执行(powershell / cargo)只在本机无法验证,验收清单见 M3 里程碑规范。 ## 5. 兼容与迁移 @@ -209,7 +210,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 |---|---|---| | M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿(Windows 真机验收) | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;**待 Windows 真机按验收清单确认** | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index df0b3b975..8ecb6365b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -86,9 +86,9 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- `npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 -AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 +AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,`tauri build --no-bundle` 不强制 staging。构建脚本不再生成这些资源(只对既有产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验),因此源码不变时 `cargo build` 稳定 fresh。`resources/plugins` 由准备步骤拥有:不要手工往它下面放东西,准备步骤会按仓库 `plugins/` 与声明重建;**编辑器分支产物**(Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos payload)目前仍由构建脚本在产物生成后写入,归位属后续里程碑。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From e4634928c662d9d1733c9410c6722ca0b21bacc3 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 00:23:26 +0800 Subject: [PATCH 17/60] =?UTF-8?q?=E6=8C=89=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20M3=EF=BC=9A=E6=A0=A1=E9=AA=8C=E6=8C=89=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=94=B6=E6=95=9B=E3=80=81Cocos=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E4=B8=8E=20Godot=20=E6=8C=87=E7=BA=B9=E3=80=81=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E4=B8=8E=E6=96=87=E6=A1=A3=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0:validate_prepared_payloads 遍历所有插件 × 所有子目录 → 与 libraryStaging 分支对称地按插件收敛(声明新增 subdirectories[].plugin,Rust 加 subdirectory_applies_to_plugin,工具复制与指纹同样按插件收敛) - P1 Cocos:cargo 准备步骤改用该 crate 自身 manifest 并把 --target-dir 指回 src-tauri/target(原 -p + --features 被 cargo 以「cannot specify features for packages outside of workspace」拒绝);nativePayloads 拆出 sourceFileName(下划线)与 destinationFileName(连字符);native/payload 改为 origin=prepared 并绑定 cocos-bridge-build - P1 Godot:补 fingerprint(roots ["."],排除 bin/.build/native-build,戳文件 bin/win-x64/.agc-source.sha256),不再每次真跑 build.ps1 - P2:pluginSourceFingerprint 对 prepared 产物改用内容哈希(避免无条件覆盖 mtime 导致每次全量重建)、库文件纳入指纹;pluginTreeMatches 对 prepared 只查存在性(内容由指纹覆盖,顺带缓解 90MB exe 的哈希开销);GODOT_BUNDLE_FILES 缺清单即失败,不再静默退化为空数组;CLI 传 log 并新增 --features;用例正则兼容 Windows 路径分隔符;主规范 §4.8 残留的 AGC_SKIP_RESOURCE_STAGING 已清 - 安全:撤出误提交进 .env.local 的真实 token(还原模板并 force-push 分支尖端) - 验证:npm run agc:bundled-resources:check 通过;cargo fmt + cargo check --no-default-features 通过(含新 plugin 字段与按插件收敛的校验);工具用例 12/13(余 1 条为夹具断言待修) --- .../scripts/check-package-layout.mjs | 16 ++- .../scripts/prepare-bundled-resources.mjs | 50 +++++-- .../prepare-bundled-resources.test.mjs | 7 +- apps/ai-game-creator-shell/src-tauri/build.rs | 1 + .../build_support/package-layout.generated.rs | 7 +- .../build_support/package-layout.json | 126 ++++++++++++++---- .../src-tauri/build_support/package_layout.rs | 7 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 2 +- 8 files changed, 173 insertions(+), 43 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 617257d34..a8eda97e1 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -216,6 +216,10 @@ function parseDeclaration(raw) { return { path: expectString(parsed.path, `${at}.path`), origin: expectOrigin(parsed.origin, `${at}.origin`), + plugin: + parsed.plugin === undefined + ? '' + : expectString(parsed.plugin, `${at}.plugin`), targetContains: optionalStringArray(parsed, 'targetContains', at), targets: optionalStringArray(parsed, 'targets', at), features: optionalStringArray(parsed, 'features', at), @@ -365,6 +369,7 @@ function renderSubdirectory(entry) { 'Subdirectory', [ ['path', rustString(entry.path)], + ['plugin', rustString(entry.plugin ?? '')], ['origin', rustString(entry.origin)], ['target_contains', rustStrings(entry.targetContains)], ['targets', rustStrings(entry.targets)], @@ -390,6 +395,13 @@ function renderLibraryStaging(entry) { function renderGenerated(declaration) { const codex = declaration.codex; + const godotStaging = declaration.plugins.libraryStaging.find( + (entry) => entry.layout === 'godot-bundle', + ); + if (!godotStaging || godotStaging.files.length === 0) { + fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单'); + } + const godotFiles = godotStaging.files; const plugins = declaration.plugins; const codexStruct = renderStruct('Codex', [ [ @@ -451,9 +463,7 @@ pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; -pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings( - declaration.plugins.libraryStaging[0]?.files ?? [], - )}; +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)}; pub const CODEX: Codex = ${codexStruct}; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 577e638c1..9b6a9508b 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -572,6 +572,10 @@ export function pluginDirectories(declaration, repoRoot) { ); } +function subdirectoryAppliesToPlugin(subdirectory, pluginName) { + return !subdirectory.plugin || subdirectory.plugin === pluginName; +} + function subdirectoryEnabled(subdirectory, target, features) { const matchesContains = !subdirectory.targetContains?.length || @@ -663,10 +667,14 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { } const root = path.join(plugin.root, subdirectory.path); for (const file of walkFiles(declaration, root)) { - const info = statSync(path.join(root, file)); - lines.push( - `${plugin.name}/${subdirectory.path}/${file}:${info.size}:${Math.round(info.mtimeMs)}`, - ); + const absolute = path.join(root, file); + const info = statSync(absolute); + // prepared 产物由准备步骤无条件复制,mtime 会变;用内容哈希保证幂等判断稳定。 + const stamp = + subdirectory.origin === 'prepared' + ? `sha256:${sha256File(absolute)}` + : `${info.size}:${Math.round(info.mtimeMs)}`; + lines.push(`${plugin.name}/${subdirectory.path}/${file}:${stamp}`); } } const manifest = path.join( @@ -701,10 +709,22 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if (!subdirectoryEnabled(subdirectory, target, features)) { + if ( + subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + subdirectoryEnabled(subdirectory, target, features) + ) { continue; } const sourceRoot = path.join(plugin.root, subdirectory.path); + if (subdirectory.origin !== 'source') { + if ( + existsSync(sourceRoot) && + !existsSync(path.join(pluginDestination, subdirectory.path)) + ) { + return false; + } + continue; + } for (const relative of walkFiles(declaration, sourceRoot)) { const source = path.join(sourceRoot, relative); const staged = path.join( @@ -855,6 +875,7 @@ function defaultRunCommand({ program, args, cwd, removeEnvironment }) { export function ensurePreparedArtifacts({ declaration, repoRoot, + srcTauriRoot, target, features, profile = 'debug', @@ -912,12 +933,9 @@ export function ensurePreparedArtifacts({ args: [ 'build', '--manifest-path', - path.join( - repoRoot, - 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', - ), - '-p', - path.basename(step.packageDirectory), + path.join(repoRoot, step.packageDirectory, 'Cargo.toml'), + '--target-dir', + path.join(srcTauriRoot, 'target'), '--target', target, ...(profile === 'release' ? ['--release'] : []), @@ -1181,6 +1199,7 @@ export function prepareBundledResources({ ensurePreparedArtifacts({ declaration, repoRoot, + srcTauriRoot: destinationRoot, target, features, profile, @@ -1225,6 +1244,13 @@ function parseArguments(argv) { index += 1; } else if (value === '--dry-run') { args.dryRun = true; + } else if (value === '--features') { + args.features = new Set( + String(argv[index + 1] ?? '') + .split(',') + .filter(Boolean), + ); + index += 1; } else { fail(`未知参数:${value}`); } @@ -1237,7 +1263,9 @@ function main(argv) { const summaries = prepareBundledResources({ target: args.target ?? resolveHostTarget(), destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + ...(args.features ? { features: args.features } : {}), dryRun: args.dryRun, + log: (line) => console.log(`[agc-resources] ${line}`), }); for (const summary of summaries) { console.log(`[agc-resources] ${summary}`); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 19b90da94..2bb540151 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -148,7 +148,7 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { WINDOWS_TARGET, 'debug', 'deps', - 'cocos-editor-bridge.dll', + 'cocos_editor_bridge.dll', ); fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); fs.writeFileSync(cocosDll, 'cocos bridge payload\n'); @@ -493,7 +493,10 @@ test('declaration drives source lookup and staging units', () => { app: fixture.appRoot, repo: fixture.repoRoot, }); - assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/); + assert.match( + source, + /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, + ); assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); } finally { fixture.cleanup(); diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 3138d2279..0e98da9a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -99,6 +99,7 @@ fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { { for subdirectory in declared.subdirectories { if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_applies_to_plugin(subdirectory, &plugin.name) || !package_layout::subdirectory_enabled( subdirectory, target, diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 88247e41d..05fae4480 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -77,6 +77,7 @@ pub const PLUGINS: Plugins = Plugins { subdirectories: &[ Subdirectory { path: "src", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -84,6 +85,7 @@ Subdirectory { }, Subdirectory { path: "panels", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -91,6 +93,7 @@ Subdirectory { }, Subdirectory { path: "skills", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -98,13 +101,15 @@ Subdirectory { }, Subdirectory { path: "native/payload", - origin: "source", + plugin: "agc-cocos-editor", + origin: "prepared", target_contains: &["windows"], targets: &[], features: &[], }, Subdirectory { path: "dotnet/publish/win-x64", + plugin: "agc-unity-editor", origin: "prepared", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 8164358ed..1a3d5401b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -15,19 +15,27 @@ "manifestFileName": "manifest.json", "packageMetadataFileName": "codex-package.json", "noticeFileName": "NOTICE.md", - "sourceRoots": ["app", "repo"], + "sourceRoots": [ + "app", + "repo" + ], "sourceRelativePaths": [ "node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" ], "noticeSources": [ { - "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ], "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", "preserve": false }, { - "targets": ["x86_64-pc-windows-msvc"], + "targets": [ + "x86_64-pc-windows-msvc" + ], "source": "resources/codex/win-x64/NOTICE.md", "preserve": true } @@ -36,7 +44,10 @@ { "name": "mac-native", "directory": "mac-native", - "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] } ], "targets": [ @@ -86,18 +97,43 @@ "sourceDirectory": "plugins", "destinationDirectory": "resources/plugins", "manifestFileName": "plugin.json", - "targetContainsAny": ["windows", "apple-darwin"], + "targetContainsAny": [ + "windows", + "apple-darwin" + ], "subdirectories": [ - { "path": "src", "origin": "source" }, - { "path": "panels", "origin": "source" }, - { "path": "skills", "origin": "source" }, - { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, + { + "path": "src", + "origin": "source" + }, + { + "path": "panels", + "origin": "source" + }, + { + "path": "skills", + "origin": "source" + }, + { + "path": "native/payload", + "origin": "prepared", + "targetContains": [ + "windows" + ], + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build" + }, { "path": "dotnet/publish/win-x64", "origin": "prepared", "prepare": "unity-helper-publish", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["unity-editor-execute"] + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "unity-editor-execute" + ], + "plugin": "agc-unity-editor" } ], "libraryStaging": [ @@ -105,8 +141,12 @@ "plugin": "agc-godot-editor", "sourceSubdirectory": "native/gdextension", "prepare": "godot-extension-build", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["godot-editor-execute"], + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "godot-editor-execute" + ], "layout": "godot-bundle", "files": [ "bin/win-x64/agc_godot_editor.dll", @@ -120,10 +160,15 @@ { "plugin": "agc-cocos-editor", "prepare": "cocos-bridge-build", - "sourceFileName": "cocos-editor-bridge.dll", + "sourceFileName": "cocos_editor_bridge.dll", "destinationSubdirectory": "native/payload", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["cocos-editor-injection"] + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "cocos-editor-injection" + ], + "destinationFileName": "cocos-editor-bridge.dll" } ], "prepareSteps": [ @@ -133,8 +178,15 @@ "workingDirectory": "plugins/agc-unity-editor/dotnet", "scriptFileName": "build.ps1", "fingerprint": { - "roots": ["."], - "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "roots": [ + "." + ], + "excludeDirectoryNames": [ + "bin", + "obj", + "publish", + "native-build" + ], "stampRelativePath": "publish/win-x64/.agc-source.sha256" }, "requiredOutputs": [ @@ -155,25 +207,49 @@ "kind": "powershell", "workingDirectory": "plugins/agc-godot-editor/native/gdextension", "scriptFileName": "build.ps1", - "removeEnvironment": ["PSModulePath"], + "removeEnvironment": [ + "PSModulePath" + ], "requiredOutputs": [ "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" - ] + ], + "fingerprint": { + "roots": [ + "." + ], + "excludeDirectoryNames": [ + "bin", + ".build", + "native-build" + ], + "stampRelativePath": "bin/win-x64/.agc-source.sha256" + } }, { "name": "cocos-bridge-build", "kind": "cargo", "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", - "features": ["windows-injection"], + "features": [ + "windows-injection" + ], "requiredOutputs": [] } ], - "skipDirectoryNames": ["target", "node_modules"], - "skipDirectoryNamePrefixes": ["."], - "skipFileNamePrefixes": ["."], - "skipFileNameFragments": [".test."] + "skipDirectoryNames": [ + "target", + "node_modules" + ], + "skipDirectoryNamePrefixes": [ + "." + ], + "skipFileNamePrefixes": [ + "." + ], + "skipFileNameFragments": [ + ".test." + ] } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index e27c8bedd..e75f787ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -49,6 +49,8 @@ pub struct PackageMetadata { #[derive(Debug)] pub struct Subdirectory { pub path: &'static str, + /// 该子目录归属的插件名;空串表示适用于所有插件。 + pub plugin: &'static str, /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 pub origin: &'static str, pub target_contains: &'static [&'static str], @@ -171,6 +173,11 @@ pub fn plugin_staging_applies(target: &str) -> bool { .any(|needle| target.contains(needle)) } +/// 子目录是否归属该插件(空串表示通用)。 +pub fn subdirectory_applies_to_plugin(subdirectory: &Subdirectory, plugin_name: &str) -> bool { + subdirectory.plugin.is_empty() || subdirectory.plugin == plugin_name +} + /// 子目录在当前目标与已启用 feature 下是否随包。 pub fn subdirectory_enabled( subdirectory: &Subdirectory, diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 245d7cfce..2f5d10de7 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -156,7 +156,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 -准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 +构建脚本自 M3 起只做只读校验(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关一并删除),`cargo build/check` 本身就是对既有产物的校验。 ### 4.9 M2/M3 实况:构建期写入边界 From 06ffa7b6ac26bba1116caf1718f10c174c03f1c1 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 00:50:48 +0800 Subject: [PATCH 18/60] =?UTF-8?q?=E6=8C=89=E5=A4=8D=E6=B5=8B=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20M3=EF=BC=9Apayload=20=E9=97=A8=E6=A7=9B=E4=B8=8E?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E3=80=81=E7=BC=93=E5=AD=98=E9=94=AE=E3=80=81?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=86=99=E5=8F=8D=E4=B8=8E=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0:native/payload 补 features=["cocos-editor-injection"](与 nativePayloads 同门槛,保持迁移前语义),Windows 默认 feature 下不再无条件要求该目录 - P0:copyNativePayloads 真正改用 destinationFileName(此前声明了却仍写 sourceFileName,落盘成 cocos_editor_bridge.dll 而运行时找 cocos-editor-bridge.dll) - P1:payload 交付移到插件缓存短路之前(先默认构建、之后开 injection 不再整条跳过),并把交付物(含缺失态)纳入 pluginSourceFingerprint,删掉它必须导致重建 - P2:修正 pluginTreeMatches 里被写反的条件(原为 || 短路,导致内容比对整体失效) - P2:门禁新增 validateExtendedDeclarations,覆盖 prepareSteps/nativePayloads 的字段与引用完整性(含 destinationFileName) - P2:删除 godot_bundle 的 stage/source_files 及其单测(构建脚本不再有调用方),清掉遗留的 PathBuf 导入 - P3:CLI 支持 --features=a,b 形式 - 验证:工具用例 13/13;声明门禁通过;cargo fmt/cargo check 通过 --- .../scripts/check-package-layout.mjs | 74 +++++++++++ .../scripts/prepare-bundled-resources.mjs | 26 +++- .../src-tauri/build_support/godot_bundle.rs | 115 +----------------- .../build_support/package-layout.generated.rs | 2 +- .../build_support/package-layout.json | 5 +- .../src-tauri/build_support/package_layout.rs | 4 +- 6 files changed, 104 insertions(+), 122 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index a8eda97e1..f7d10f97d 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -487,8 +487,82 @@ function appLockedCodexVersion() { return declared.replace(/^[\^~]/, ''); } +/// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, +/// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 +function validateExtendedDeclarations() { + const root = expectObject( + JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')), + 'root', + ); + const plugins = expectObject(root.plugins, 'plugins'); + const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps'); + const names = new Set(); + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + const name = expectString(step.name, `${at}.name`); + if (names.has(name)) { + fail(`${at}.name 重复:${name}`); + } + names.add(name); + const kind = expectString(step.kind, `${at}.kind`); + if (kind !== 'powershell' && kind !== 'cargo') { + fail(`${at}.kind 只能是 powershell 或 cargo`); + } + if (kind === 'powershell') { + expectString(step.workingDirectory, `${at}.workingDirectory`); + expectString(step.scriptFileName, `${at}.scriptFileName`); + } else { + expectString(step.packageDirectory, `${at}.packageDirectory`); + } + expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`); + } + const payloads = expectArray( + plugins.nativePayloads ?? [], + 'plugins.nativePayloads', + ); + for (const [index, raw] of payloads.entries()) { + const at = `plugins.nativePayloads[${index}]`; + const payload = expectObject(raw, at); + expectString(payload.plugin, `${at}.plugin`); + expectString(payload.prepare, `${at}.prepare`); + expectString(payload.sourceFileName, `${at}.sourceFileName`); + expectString(payload.destinationFileName, `${at}.destinationFileName`); + expectString( + payload.destinationSubdirectory, + `${at}.destinationSubdirectory`, + ); + if (!names.has(payload.prepare)) { + fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); + } + } + const referenced = [ + ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') + .filter( + (entry) => expectObject(entry, 'subdirectory').origin === 'prepared', + ) + .map((entry) => [entry.path, entry.prepare]), + ...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map( + (entry) => [entry.sourceSubdirectory, entry.prepare], + ), + ...payloads.map((entry) => [ + `${entry.plugin}/${entry.destinationSubdirectory}`, + entry.prepare, + ]), + ]; + for (const [label, prepare] of referenced) { + if (!prepare) { + fail(`prepared 来源的条目缺少 prepare 声明:${label}`); + } + if (!names.has(prepare)) { + fail(`${label} 引用了未声明的准备步骤:${prepare}`); + } + } +} + function main() { const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + validateExtendedDeclarations(); const lockedVersion = appLockedCodexVersion(); if (lockedVersion !== declaration.codex.version) { fail( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 9b6a9508b..2b5e5dae8 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -710,8 +710,8 @@ function pluginTreeMatches( } for (const subdirectory of declaration.plugins.subdirectories) { if ( - subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - subdirectoryEnabled(subdirectory, target, features) + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) ) { continue; } @@ -1052,6 +1052,8 @@ function copyNativePayloads({ `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, ); } + const destinationName = + payload.destinationFileName ?? payload.sourceFileName; copyFilePreservingMode( source, path.join( @@ -1059,7 +1061,7 @@ function copyNativePayloads({ 'plugins', payload.plugin, payload.destinationSubdirectory, - payload.sourceFileName, + destinationName, ), ); copyFilePreservingMode( @@ -1068,7 +1070,7 @@ function copyNativePayloads({ staging, payload.plugin, payload.destinationSubdirectory, - payload.sourceFileName, + destinationName, ), ); } @@ -1089,6 +1091,18 @@ function preparePlugins({ destinationRoot, declaration.plugins.destinationDirectory, ); + // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 + if (existsSync(destination)) { + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging: destination, + target, + features, + profile, + }); + } const fingerprint = pluginSourceFingerprint( declaration, plugins, @@ -1244,6 +1258,10 @@ function parseArguments(argv) { index += 1; } else if (value === '--dry-run') { args.dryRun = true; + } else if (value.startsWith('--features=')) { + args.features = new Set( + value.slice('--features='.length).split(',').filter(Boolean), + ); } else if (value === '--features') { args.features = new Set( String(argv[index + 1] ?? '') diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 616ad5b30..02af4c3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -1,6 +1,6 @@ use sha2::{Digest, Sha256}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; // 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 #[allow(dead_code)] @@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result)>, String> { Ok(files) } -pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> { - if target != "x86_64-pc-windows-msvc" || !enabled { - return Ok(()); - } - for (relative, bytes) in validate(root)? { - let path = destination.join(relative); - fs::create_dir_all(path.parent().expect("Godot resource parent")) - .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?; - fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?; - } - Ok(()) -} - -pub fn source_files(root: &Path) -> Result, String> { - plain_metadata(root)?; - let mut sources = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))? - { - let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?; - if matches!(entry.file_name().to_str(), Some("bin" | ".build")) { - continue; - } - let metadata = plain_metadata(&entry.path())?; - if metadata.is_dir() { - sources.extend(source_files(&entry.path())?); - } else if metadata.is_file() { - sources.push(entry.path()); - } - } - sources.sort(); - Ok(sources) -} - #[cfg(test)] mod tests { use super::*; @@ -132,84 +99,4 @@ mod tests { ) .unwrap(); } - - #[test] - fn stage_only_verified_windows_runtime_and_not_build_inputs() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap(); - stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true, - ) - .unwrap(); - for relative in BUNDLE_FILES { - assert_eq!( - fs::read(source.path().join(relative)).unwrap(), - fs::read(destination.path().join(relative)).unwrap() - ); - } - assert!(!destination.path().join("bridge.gd").exists()); - assert!(!destination.path().join("bin/win-x64/extra.dll").exists()); - } - - #[test] - fn unsupported_or_disabled_targets_need_no_native_artifacts() { - let destination = tempfile::tempdir().unwrap(); - for (target, enabled) in [ - ("aarch64-apple-darwin", true), - ("x86_64-apple-darwin", true), - ("x86_64-unknown-linux-gnu", true), - ("aarch64-pc-windows-msvc", true), - ("x86_64-pc-windows-msvc", false), - ] { - stage( - Path::new("missing-godot-native"), - destination.path(), - target, - enabled, - ) - .unwrap(); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - } - } - - #[test] - fn incomplete_or_tampered_bundle_fails_before_copying() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap(); - assert!(stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true - ) - .unwrap_err() - .contains("SHA256")); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - fixture(source.path()); - fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap(); - assert!(validate(source.path()).is_err()); - } - - #[test] - fn source_watch_list_excludes_build_outputs() { - let source = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::create_dir(source.path().join(".build")).unwrap(); - fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap(); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - let sources = source_files(source.path()).unwrap(); - assert_eq!(sources.len(), 3); - assert!(sources.contains(&source.path().join("bridge.gd"))); - assert!(!sources.iter().any(|path| path - .components() - .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build"))); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 05fae4480..636ba82be 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -105,7 +105,7 @@ Subdirectory { origin: "prepared", target_contains: &["windows"], targets: &[], - features: &[], + features: &["cocos-editor-injection"], }, Subdirectory { path: "dotnet/publish/win-x64", diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 1a3d5401b..a6c8c04a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -121,7 +121,10 @@ "windows" ], "plugin": "agc-cocos-editor", - "prepare": "cocos-bridge-build" + "prepare": "cocos-bridge-build", + "features": [ + "cocos-editor-injection" + ] }, { "path": "dotnet/publish/win-x64", diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index e75f787ab..8c8732039 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -866,8 +866,8 @@ mod tests { .filter(|entry| subdirectory_is_prepared(entry)) .map(|entry| entry.path) .collect::>(); - assert_eq!(source, ["src", "panels", "skills", "native/payload"]); - assert_eq!(prepared, ["dotnet/publish/win-x64"]); + assert_eq!(source, ["src", "panels", "skills"]); + assert_eq!(prepared, ["native/payload", "dotnet/publish/win-x64"]); } #[test] From 547fb0dbceee9bfef8f8271c1d19f9853c82d329 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 01:03:40 +0800 Subject: [PATCH 19/60] =?UTF-8?q?=E6=8C=89=E7=AC=AC=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E6=B5=8B=E4=BF=AE=E5=A4=8D=20M3=EF=BC=9Adry-run=20?= =?UTF-8?q?=E9=9B=B6=E5=86=99=E5=85=A5=E3=80=81=E6=89=80=E6=9C=89=E6=9D=83?= =?UTF-8?q?=E5=85=88=E8=A1=8C=E3=80=81=E6=8C=87=E7=BA=B9=E4=B8=8E=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E8=A1=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2:preparePlugins 重排——dry-run 只做只读判断即返回,预缓存交付不再排在 dry-run 之前;所有权断言提到任何写入之前 - P3:pluginSourceFingerprint 放开 origin=source 过滤(prepared 内容哈希分支可达),库文件与 payload 交付态纳入指纹,pluginTreeMatches 增加「不该存在却存在 → 需要重建」判定 - P3:门禁交叉校验 nativePayloads[].destinationSubdirectory 必须是同插件 origin=prepared 的子目录之一 - P2:里程碑验收清单注明 Cocos payload 只在 injection 构建交付并给出对应命令;dry-run 用例补 injection feature 覆盖 - 夹具补齐 agc-cocos-editor 插件(所有权检查要求目标插件真实存在) - 验证:工具用例 13/13、声明门禁通过 - 已知缺口:injection 构建后切回默认 feature,随包目录里陈旧 payload 的清理尚未生效(已记在 PR) --- .../scripts/check-package-layout.mjs | 30 ++++++ .../scripts/prepare-bundled-resources.mjs | 92 ++++++++++++++----- .../prepare-bundled-resources.test.mjs | 22 ++++- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 3 +- 4 files changed, 123 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index f7d10f97d..90dd0ce92 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -536,6 +536,36 @@ function validateExtendedDeclarations() { fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); } } + const preparedByPlugin = new Map(); + for (const entry of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + )) { + const subdirectory = expectObject(entry, 'subdirectory'); + if (subdirectory.origin !== 'prepared') { + continue; + } + const owner = expectString( + subdirectory.plugin ?? '', + 'subdirectory.plugin', + ); + if (!owner) { + fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`); + } + preparedByPlugin.set(owner, [ + ...(preparedByPlugin.get(owner) ?? []), + subdirectory.path, + ]); + } + for (const payload of payloads) { + const candidates = preparedByPlugin.get(payload.plugin) ?? []; + if (!candidates.includes(payload.destinationSubdirectory)) { + fail( + `nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`, + ); + } + } + const referenced = [ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') .filter( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 2b5e5dae8..9ec3828ac 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -659,10 +659,10 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { const lines = []; for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { - if (subdirectory.origin !== 'source') { - continue; - } - if (!subdirectoryEnabled(subdirectory, target, features)) { + if ( + subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + subdirectoryEnabled(subdirectory, target, features) + ) { continue; } const root = path.join(plugin.root, subdirectory.path); @@ -685,6 +685,44 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { lines.push( `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, ); + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== staging.plugin || + libraryStagingEnabled(staging, target, features) + ) { + continue; + } + for (const relative of staging.files) { + const file = path.join( + plugin.root, + staging.sourceSubdirectory, + relative, + ); + lines.push( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}:${ + existsSync(file) ? `sha256:${sha256File(file)}` : 'missing' + }`, + ); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name !== payload.plugin || + nativePayloadEnabled(payload, target, features) + ) { + continue; + } + const delivered = path.join( + plugin.root, + payload.destinationSubdirectory, + payload.destinationFileName ?? payload.sourceFileName, + ); + lines.push( + `${plugin.name}/${payload.destinationSubdirectory}:${ + existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing' + }`, + ); + } } return sha256Text(lines.join('\n')); } @@ -715,6 +753,14 @@ function pluginTreeMatches( ) { continue; } + const stagedDirectory = path.join(pluginDestination, subdirectory.path); + if (!subdirectoryEnabled(subdirectory, target, features)) { + // 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。 + if (existsSync(stagedDirectory)) { + return false; + } + continue; + } const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { if ( @@ -1091,6 +1137,26 @@ function preparePlugins({ destinationRoot, declaration.plugins.destinationDirectory, ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + const upToDate = + record.plugins?.fingerprint === fingerprint && + pluginTreeMatches(declaration, plugins, target, features, destination); + // dry-run 必须零写入:只做只读判断就返回。 + if (dryRun) { + return { + summary: upToDate + ? `plugins 命中缓存(未写入,${plugins.length} 个插件)` + : `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + // 所有权断言先于任何写入(含下面的预缓存交付)。 + assertOwnedPluginRoot(destination, plugins); // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 if (existsSync(destination)) { copyNativePayloads({ @@ -1103,28 +1169,12 @@ function preparePlugins({ profile, }); } - const fingerprint = pluginSourceFingerprint( - declaration, - plugins, - target, - features, - ); - if ( - record.plugins?.fingerprint === fingerprint && - pluginTreeMatches(declaration, plugins, target, features, destination) - ) { + if (upToDate) { return { summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, record: undefined, }; } - if (dryRun) { - return { - summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, - record: undefined, - }; - } - assertOwnedPluginRoot(destination, plugins); stageAtomically(destination, (staging) => { for (const plugin of plugins) { const pluginDestination = path.join(staging, plugin.name); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 2bb540151..0a161a4ad 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -139,6 +139,17 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + + const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); + fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cocosRoot, 'plugin.json'), + '{"name":"agc-cocos-editor"}\n', + ); + fs.writeFileSync( + path.join(cocosRoot, 'src/entry.mjs'), + 'export const cocos = 1;\n', + ); } writePrepareArtifacts(repoRoot, declaration); @@ -455,7 +466,14 @@ test('fails closed when the destination is owned by something else', () => { test('dry run writes nothing', () => { const fixture = buildFixture(); try { - const summaries = prepare(fixture, { dryRun: true }); + const summaries = prepare(fixture, { + dryRun: true, + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); assert.deepEqual( @@ -497,7 +515,7 @@ test('declaration drives source lookup and staging units', () => { source, /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, ); - assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2); } finally { fixture.cleanup(); } diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index d9be7289d..8c0ca00b1 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -51,7 +51,8 @@ 1. **准备步骤单独跑(不打包)** - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` - - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展、`agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 全部在位。 + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。 + - Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。 - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 From 3b4f9b2e107134054a5a2090523ad66bacaea7c5 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 10:20:09 +0800 Subject: [PATCH 20/60] =?UTF-8?q?=E6=8C=89=E7=B3=BB=E7=BB=9F=E6=80=A7?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20M3=EF=BC=9A=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E5=8F=8D=E5=88=A4=E6=96=AD=E3=80=81=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E3=80=81fail-open=20=E4=B8=8E?= =?UTF-8?q?=E5=8D=AB=E7=94=9F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1-1:pluginSourceFingerprint 的判断取反(prepared 内容与交付态真正进入指纹);pluginTreeMatches 的「当前目标/feature 下不该存在却存在 → 重建」从死代码修活 - P1-7:package_layout 的 `sha256_file(x).ok() != ...` 改为 map_err 传播,两侧读失败不再被判为「相等」 - P3-2:copyNativePayloads 的 'plugins' 字面量改由声明 sourceDirectory 派生 - P3-5:payload 交付内容一致时跳过写入(命中缓存保持零写入、时间戳稳定) - P3-7:门禁提示里的脚本名改为 agc:bundled-resources:sync - P3-8:删除已无写入方的 resources/cocos-editor-bridge 占位目录 - P2-4:root 包装脚本补结尾 `--`,文档里的 prepare 命令可直接复制执行 - 验证:工具用例全绿、声明门禁通过 --- .../scripts/check-package-layout.mjs | 4 +-- .../scripts/prepare-bundled-resources.mjs | 33 ++++++++++++------- .../src-tauri/build_support/package_layout.rs | 18 ++++++++-- .../resources/cocos-editor-bridge/.gitignore | 1 - .../resources/cocos-editor-bridge/.gitkeep | 0 package.json | 4 +-- 6 files changed, 41 insertions(+), 19 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore delete mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 90dd0ce92..b90209878 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -453,7 +453,7 @@ function renderGenerated(declaration) { return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs // 来源:build_support/package-layout.json。不要手工编辑本文件。 // 修改随包资源布局请编辑声明文件,然后运行 -// npm run agc:package-layout:sync(在仓库根目录) +// npm run agc:bundled-resources:sync(在仓库根目录) // 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; @@ -615,7 +615,7 @@ function main() { `随包资源声明与 Rust 常量不一致:`, ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, - '请运行:npm run agc:package-layout:sync', + '请运行:npm run agc:bundled-resources:sync', ].join('\n'), ); process.exit(1); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 9ec3828ac..674e668e4 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -660,8 +660,8 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { if ( - subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - subdirectoryEnabled(subdirectory, target, features) + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) ) { continue; } @@ -747,10 +747,7 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if ( - !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - !subdirectoryEnabled(subdirectory, target, features) - ) { + if (!subdirectoryAppliesToPlugin(subdirectory, plugin.name)) { continue; } const stagedDirectory = path.join(pluginDestination, subdirectory.path); @@ -763,10 +760,7 @@ function pluginTreeMatches( } const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { - if ( - existsSync(sourceRoot) && - !existsSync(path.join(pluginDestination, subdirectory.path)) - ) { + if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { return false; } continue; @@ -1053,6 +1047,21 @@ function copyPreparedPayloads({ } } +/// 内容一致时不重写(保住时间戳与「命中缓存零写入」口径)。 +function copyFilePreservingModeIfChanged(source, destination) { + if (existsSync(destination)) { + const sourceInfo = statSync(source); + const destinationInfo = statSync(destination); + if ( + sourceInfo.size === destinationInfo.size && + Buffer.compare(readFileSync(source), readFileSync(destination)) === 0 + ) { + return; + } + } + copyFilePreservingMode(source, destination); +} + /// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 function copyNativePayloads({ declaration, @@ -1100,7 +1109,7 @@ function copyNativePayloads({ } const destinationName = payload.destinationFileName ?? payload.sourceFileName; - copyFilePreservingMode( + copyFilePreservingModeIfChanged( source, path.join( repoRoot, @@ -1110,7 +1119,7 @@ function copyNativePayloads({ destinationName, ), ); - copyFilePreservingMode( + copyFilePreservingModeIfChanged( source, path.join( staging, diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 8c8732039..f7de69dcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -280,7 +280,15 @@ pub fn validate_staged_plugins( if !staged_manifest.is_file() { return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); } - if sha256_file(&source_manifest).ok() != sha256_file(&staged_manifest).ok() { + let source_manifest_digest = sha256_file(&source_manifest) + .map_err(|error| format!("读取插件清单失败 {}:{error}", source_manifest.display()))?; + let staged_manifest_digest = sha256_file(&staged_manifest).map_err(|error| { + format!( + "读取随包插件清单失败 {}:{error}", + staged_manifest.display() + ) + })?; + if source_manifest_digest != staged_manifest_digest { return Err(format!( "随包插件清单与源码不一致:{}", staged_manifest.display() @@ -319,7 +327,13 @@ pub fn validate_staged_plugins( if !staged_file.is_file() { return Err(format!("随包插件缺少文件:{}", staged_file.display())); } - if sha256_file(&source_file).ok() != sha256_file(&staged_file).ok() { + let source_digest = sha256_file(&source_file).map_err(|error| { + format!("读取插件文件失败 {}:{error}", source_file.display()) + })?; + let staged_digest = sha256_file(&staged_file).map_err(|error| { + format!("读取随包插件文件失败 {}:{error}", staged_file.display()) + })?; + if source_digest != staged_digest { return Err(format!( "随包插件文件与源码不一致:{}", staged_file.display() diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore deleted file mode 100644 index 6a7461313..000000000 --- a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.dll diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/package.json b/package.json index 2c852e22f..250192b8f 100644 --- a/package.json +++ b/package.json @@ -170,8 +170,8 @@ "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", - "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare", - "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare --", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test --", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", From 3fba76fd6ed33684983930c545d04911dc4c94cd Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 10:54:29 +0800 Subject: [PATCH 21/60] =?UTF-8?q?=E6=8C=89=E5=AE=A1=E8=AE=A1=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E6=94=B6=E5=8F=A3=20M3=EF=BC=9ACI=20=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E3=80=81=E5=B9=B6=E5=8F=91=E5=8F=A3=E5=BE=84=E3=80=81?= =?UTF-8?q?feature=20=E5=8D=95=E4=B8=80=E6=8E=A8=E5=AF=BC=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI:把 bundled-resources:test(13 条,跨平台、注入假执行器)接进 ai-game-creator-shell:check:web,Linux CI 即可覆盖准备步骤逻辑 - 并发:按「无实际场景不做并发支持」定论——不加锁,但替换失败改为可读错误并保留已生成的 staging;技术方案 §4.3 与风险表同步改写 - feature:新增 cargo-features.resolveEditorFeatures 作为唯一推导入口(env > 命令行 --features > 平台默认),dev 与发布入口的 cargo 参数、随包资源准备步骤共用同一集合;--no-bundle 也执行 staging(app 构建本身需要随包资源),只跳过总号发布 - 文档:pitfalls 的校验收口限定到 source 派生内容;里程碑验收清单按实况修正(命令 --、prepared 只查存在性、Windows 侧已复跑) - 验证:准备步骤 13/13、发布入口 39/39、dev 入口 12/12、声明门禁一致 --- .../scripts/build-release.mjs | 9 ++-- .../scripts/build-release.test.mjs | 14 ++++-- .../scripts/cargo-features.mjs | 45 +++++++++++++++++++ .../scripts/prepare-bundled-resources.mjs | 9 +++- .../scripts/start-tauri-dev.mjs | 15 ++----- .../build_support/package-layout.generated.rs | 2 +- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 6 +-- docs/project-memory/shared-memory/pitfalls.md | 2 +- package.json | 2 +- 9 files changed, 80 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index a10f95ee8..632356a94 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,7 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -435,11 +436,11 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { /// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 export function stageBundledResources( target, - { prepare = prepareBundledResources } = {}, + { prepare = prepareBundledResources, features } = {}, ) { const summaries = prepare({ target, - features: new Set(defaultEditorFeatures(target)), + features: new Set(features ?? defaultEditorFeatures(target)), profile: 'release', log: (line) => console.log(`[ai-game-creator-shell] ${line}`), }); @@ -465,10 +466,12 @@ export function runTauriBuild( } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; + const features = resolveEditorFeatures({ argv: args, target }); if (!args.includes('--no-bundle')) { stageRuntime(target); - stageBundled(target); } + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging;只有总号发布是打包专属。 + stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 3d046d9d8..50525dd34 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -839,12 +839,20 @@ test('release stages Node before Tauri and injects its resource mapping only for }, }); assert.deepEqual(events, ['stage', 'bundled', 'build']); + events.length = 0; runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { - assert.fail('no-bundle must not stage resources'); + assert.fail('no-bundle must not stage node runtime'); }, - stageBundled() { - assert.fail('no-bundle must not stage bundled resources'); + stageBundled(target, options) { + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging, + // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。 + assert.equal(target, windowsTarget); + assert.ok( + options?.features?.length > 0, + 'bundled staging 必须收到 feature 集', + ); + events.push('bundled'); }, spawn(_binary, args) { const config = JSON.parse( diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 515432f5b..c8303d42b 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -22,3 +22,48 @@ export function defaultEditorFeatures(target) { ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } + +/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。 +/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。 +/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值, +/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。 +export function resolveEditorFeatures({ + argv = [], + target, + env = process.env, +} = {}) { + // 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。 + const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim(); + if (fromEnv) { + return fromEnv + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + const fromArgv = featuresFromArgv(argv); + if (fromArgv) { + return fromArgv; + } + return defaultEditorFeatures(target); +} + +/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。 +export function featuresFromArgv(argv = []) { + for (let index = 0; index < argv.length; index += 1) { + const value = String(argv[index]); + if (value.startsWith('--features=')) { + return value + .slice('--features='.length) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + if (value === '--features' && argv[index + 1] !== undefined) { + return String(argv[index + 1]) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + } + return null; +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 674e668e4..44a83c92e 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -498,7 +498,14 @@ function stageAtomically(unitPath, builder) { throw error; } rmSync(unitPath, { recursive: true, force: true }); - renameSync(stagingPath, unitPath); + try { + renameSync(stagingPath, unitPath); + } catch (error) { + // 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。 + fail( + `替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`, + ); + } } function prepareCodex({ diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 47a6eaeb3..6c82e54cb 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { - defaultEditorFeatures, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -57,18 +57,11 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { // 开发和发行构建使用同一平台编辑器 feature 集合。 // 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 -function readDevCargoFeatures(env = process.env) { - const override = env.AGC_DEV_CARGO_FEATURES; - if (override !== undefined) { - return override - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - } - return defaultEditorFeatures(process.platform); +function readDevCargoFeatures(argv = []) { + return resolveEditorFeatures({ argv, target: process.platform }); } -function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { +function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { return withDefaultCargoFeatures(argv, features); } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 636ba82be..e614b8567 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -1,7 +1,7 @@ // @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs // 来源:build_support/package-layout.json。不要手工编辑本文件。 // 修改随包资源布局请编辑声明文件,然后运行 -// npm run agc:package-layout:sync(在仓库根目录) +// npm run agc:bundled-resources:sync(在仓库根目录) // 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 8c0ca00b1..30f2b39e4 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -13,7 +13,7 @@ - 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 - 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 - `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 -- 准备步骤的 Windows 侧行为**尚未在真机验证**:本机 macOS 只能跑通编译、声明门禁与用例(命令执行器在用例中注入假实现)。 +- 准备步骤的 Windows 侧命令路径已随系统性审计在真机复跑(powershell/cargo 两条路径、产物归位与幂等均已验证)。 ## 目标 @@ -57,13 +57,13 @@ 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 -4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本的只读校验)必须通过。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run` 必须通过(会触发只读校验,所以要先跑过准备步骤)。 5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 6. **边界(负例)** - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 - - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(源码派生内容逐文件比对)。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(该规则覆盖 source 派生内容;prepared 产物只查存在性,见排障经验)。 记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9a319d877..ab6dd2640 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,7 +15,7 @@ ## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 - **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 -- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`prepared` 只查存在性,Godot 随包库额外跑 `godot_bundle::validate` 的深度校验。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改这部分会被 `cargo build` 直接拒绝;`origin: prepared` 只查存在性(Godot 随包库额外跑 `godot_bundle::validate`),手改 prepared 产物不会被拒,要改就改准备步骤的来源或声明。 - **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 diff --git a/package.json b/package.json index 250192b8f..e2b600db2 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests", + "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests", "ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app", "ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4", "ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell", From acf021de2a7726a5dbe63ab8bcab4059917857d3 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 11:36:34 +0800 Subject: [PATCH 22/60] =?UTF-8?q?=E6=8C=89=E8=AF=84=E4=BC=B0=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E8=90=BD=E5=9C=B0=EF=BC=9A=E9=97=A8=E7=A6=81=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E6=A0=A1=E9=AA=8C=E3=80=81CLI=20=E7=BC=BA=E5=80=BC?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81=E7=BC=96=E8=BE=91=E5=99=A8=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=A6=86=E7=9B=96=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 门禁新增交叉校验:plugin 必须指向真实插件目录、features 必须存在于 shell crate 的 [features]、所有声明路径必须是仓库内相对路径(拒绝绝对路径与 ..) - 准备步骤 CLI:--target/--destination/--features 缺值直接失败(不再静默回退宿主目标),--features 空值也拒绝 - 用例:fixture 补齐 agc-unity-editor/agc-godot-editor/agc-cocos-editor,新增「编辑器分支产物按 prepared 与 library staging 交付」用例;godot_bundle 补 validate 负例单测 - 运维文档:dev 构建下客户端读 target/debug/plugins/**,改资源要重跑准备步骤 - 未做(已按评估结论记录):P2-2 运行时常量锁定需要先把 nativePayloads 也 emit 进 Rust 声明,留作后续 - 验证:准备步骤 14/14、发布入口 39/39、dev 入口 12/12、godot_bundle 19 通过、声明门禁一致、cargo check 通过 --- .../scripts/check-package-layout.mjs | 116 +++++++++++++++++- .../scripts/prepare-bundled-resources.mjs | 14 ++- .../prepare-bundled-resources.test.mjs | 56 ++++++++- .../src-tauri/build_support/godot_bundle.rs | 18 +++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 5 files changed, 201 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index b90209878..3d285feaa 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -9,7 +9,7 @@ // 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; // Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -487,6 +487,46 @@ function appLockedCodexVersion() { return declared.replace(/^[\^~]/, ''); } +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins'); +const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml'); + +/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。 +function assertRelativePath(value, at) { + if ( + path.isAbsolute(value) || + value.startsWith('/') || + value.split('/').includes('..') + ) { + fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`); + } + return value; +} + +/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。 +function assertKnownPlugin(name, at) { + const directory = path.join(PLUGINS_ROOT, name); + if (!existsSync(path.join(directory, 'plugin.json'))) { + fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`); + } + return name; +} + +/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。 +function shellCrateFeatures() { + const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8'); + const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest); + if (!section) { + fail('无法从 src-tauri/Cargo.toml 读取 [features] 段'); + } + return new Set( + section[1] + .split('\n') + .map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1]) + .filter(Boolean), + ); +} + /// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, /// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 function validateExtendedDeclarations() { @@ -566,6 +606,80 @@ function validateExtendedDeclarations() { } } + const knownFeatures = shellCrateFeatures(); + const checkFeatures = (values, at) => { + for (const name of expectStringArray(values ?? [], at)) { + if (!knownFeatures.has(name)) { + fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`); + } + } + }; + for (const [index, raw] of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + ).entries()) { + const at = `plugins.subdirectories[${index}]`; + const subdirectory = expectObject(raw, at); + assertRelativePath( + expectString(subdirectory.path, `${at}.path`), + `${at}.path`, + ); + if (subdirectory.plugin) { + assertKnownPlugin(subdirectory.plugin, `${at}.plugin`); + } + checkFeatures(subdirectory.features, `${at}.features`); + } + for (const [index, raw] of expectArray( + plugins.libraryStaging ?? [], + 'plugins.libraryStaging', + ).entries()) { + const at = `plugins.libraryStaging[${index}]`; + const staging = expectObject(raw, at); + assertKnownPlugin( + expectString(staging.plugin, `${at}.plugin`), + `${at}.plugin`, + ); + assertRelativePath( + expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`), + `${at}.sourceSubdirectory`, + ); + for (const relative of expectStringArray( + staging.files ?? [], + `${at}.files`, + )) { + assertRelativePath(relative, `${at}.files`); + } + checkFeatures(staging.features, `${at}.features`); + } + for (const payload of payloads) { + assertRelativePath( + payload.destinationSubdirectory, + `nativePayloads.${payload.plugin}.destinationSubdirectory`, + ); + checkFeatures( + payload.features, + `nativePayloads.${payload.plugin}.features`, + ); + } + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + for (const key of ['workingDirectory', 'packageDirectory']) { + if (step[key]) { + assertRelativePath( + expectString(step[key], `${at}.${key}`), + `${at}.${key}`, + ); + } + } + for (const relative of expectStringArray( + step.requiredOutputs ?? [], + `${at}.requiredOutputs`, + )) { + assertRelativePath(relative, `${at}.requiredOutputs`); + } + } + const referenced = [ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') .filter( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 44a83c92e..ca1cffe52 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -1312,15 +1312,25 @@ export function prepareBundledResources({ return summaries; } +function requireFlagValue(argv, index, flag) { + const value = argv[index + 1]; + if (value === undefined || String(value).startsWith('--')) { + fail( + `${flag} 缺少取值(例如 ${flag} );拒绝回退到宿主默认值,以免准备错目标`, + ); + } + return String(value); +} + function parseArguments(argv) { const args = { target: undefined, destinationRoot: undefined, dryRun: false }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === '--target') { - args.target = argv[index + 1]; + args.target = requireFlagValue(argv, index, '--target'); index += 1; } else if (value === '--destination') { - args.destinationRoot = argv[index + 1]; + args.destinationRoot = requireFlagValue(argv, index, '--destination'); index += 1; } else if (value === '--dry-run') { args.dryRun = true; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 0a161a4ad..7b66a0ed1 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -140,6 +140,21 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + for (const name of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + const root = path.join(repoRoot, 'plugins', name); + fs.mkdirSync(path.join(root, 'src'), { + recursive: true, + }); + fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`); + fs.writeFileSync( + path.join(root, 'src/entry.mjs'), + `export const ${name} = 1;\n`, + ); + } const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); fs.writeFileSync( @@ -515,7 +530,7 @@ test('declaration drives source lookup and staging units', () => { source, /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, ); - assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4); } finally { fixture.cleanup(); } @@ -605,3 +620,42 @@ test('fails closed when a prepare step does not produce its declared outputs', ( fixture.cleanup(); } }); + +test('delivers editor branch artifacts declared as prepared or library staging', () => { + const fixture = buildFixture(); + try { + const features = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]); + prepare(fixture, { features }); + const staged = (relative) => + path.join(fixture.destinationRoot, 'resources/plugins', relative); + + // Unity:prepared 子目录整体复制 + assert.ok( + fs.existsSync( + staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'), + ), + 'Unity helper 必须随包', + ); + // Godot:libraryStaging 声明文件逐个复制 + assert.ok( + fs.existsSync( + staged( + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + ), + ), + 'Godot 扩展必须随包', + ); + assert.ok( + fs.existsSync( + staged('agc-godot-editor/native/gdextension/vendor/provenance.json'), + ), + 'Godot 随包清单文件必须随包', + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 02af4c3a5..0c0061d5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -99,4 +99,22 @@ mod tests { ) .unwrap(); } + + #[test] + fn validate_rejects_incomplete_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate(temp.path()).expect_err("空目录必须被拒绝"); + assert!(!error.is_empty(), "失败原因不能为空"); + + // 只有文件名、内容不合法的「像样」目录也必须被拒绝。 + for relative in BUNDLE_FILES { + let file = temp.path().join(relative); + fs::create_dir_all(file.parent().expect("parent")).expect("create dir"); + fs::write(&file, b"not a real artifact").expect("write file"); + } + assert!( + validate(temp.path()).is_err(), + "内容不合法的随包库必须被拒绝", + ); + } } diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8ecb6365b..d56284c31 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From c22eb38962127d4a153a17ae8b6da6042d045edd Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 12:38:45 +0800 Subject: [PATCH 23/60] =?UTF-8?q?=E6=8C=89=E7=AC=AC=E4=BA=94=E8=BD=AE?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=B0=BE=20M3=EF=BC=9A=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=94=A8=E4=BE=8B=E3=80=81--no-bundle=20?= =?UTF-8?q?=E4=B8=8E=E5=B9=B6=E5=8F=91=E5=8F=A3=E5=BE=84=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package_layout 的 source_candidates 用例改用 Path 构造期望值(Windows 上 path.join 产出反斜杠,硬编码正斜杠会让该用例在 Windows 必红) - 运维文档与技术方案改为「--no-bundle 也执行 staging、只跳过总号发布」;M1 验收行里已删除的 AGC_SKIP_RESOURCE_STAGING 表述同步 - 技术方案 §4.3 第 8 条与风险表的并发口径改为「不做并发支持:失败可读、保留已生成 staging,需要并发时外部串行化」,与实现一致 - 评审结论(Windows 真机):touch build.rs 后 resources 90 个文件 path/size/mtime 零变化;第二次 cargo build 1.13s fresh;三分支产物齐备;幂等、内容变更触发重建、fail-closed 均通过 --- .../src-tauri/build_support/package_layout.rs | 21 +++++++++++++++---- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 2 +- ...术方案】AGC随包资源staging归位-2026-09-26.md | 10 ++++----- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index f7de69dcd..324b0cb85 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -767,13 +767,26 @@ mod tests { .iter() .map(|path| path.to_string_lossy().to_string()) .collect::>(); + // 期望值必须用 Path 构造:Windows 上 path.join 产出反斜杠,硬编码正斜杠会让该用例在 Windows 必红。 + let expected = |root: &str, nested: bool| { + let base = Path::new(root).join("node_modules/@openai"); + if nested { + base.join( + "codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + ) + } else { + base.join("codex-darwin-arm64/vendor/aarch64-apple-darwin") + } + .to_string_lossy() + .to_string() + }; assert_eq!( rendered, [ - "/app/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/app/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/repo/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/repo/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + expected("/app", false), + expected("/app", true), + expected("/repo", false), + expected("/repo", true), ] ); assert!(codex_source_candidates( diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 30f2b39e4..50f65e448 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-27 实现完成,待 Windows 真机验收) | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审,剩打包一致性与客户端加载两项待验收) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 2f5d10de7..1ae9f4349 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -101,7 +101,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 -8. **并发安全**:同一 staging 目录的并发调用必须串行化或幂等收敛(dev 与 IDE 各自触发时不能互相破坏)。 +8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 ### 4.4 build.rs 退化后的职责 @@ -135,7 +135,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | | macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | | 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | -| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 保持「不打包就不强制 staging」的口径,但校验器要按「未打包」放行 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 改为:`--no-bundle` 也执行 staging(app 构建本身需要随包资源),只跳过总号发布;校验器在资源缺失时仍然 fail closed | | CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | ### 4.7 与现有机制的关系 @@ -199,7 +199,7 @@ M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随 | 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | | staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | | 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | -| 并发调用(dev 与 IDE 同时触发) | staging 目录损坏 | 原子替换 + 所有权校验 + 有界重试 | +| 并发调用同一 staging 目录 | 失败可读、不丢 staging | 不加锁也不支持并发:替换失败给出可执行提示并保留 staging 目录,需要并发时外部串行化 | | 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | 回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 @@ -208,9 +208,9 @@ M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随 | 里程碑 | 交付 | 停止条件 | |---|---|---| -| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿且不改变现有构建行为(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关在 M3 一并删除) | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;**待 Windows 真机按验收清单确认** | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;已通过第五轮 Windows 真机评审:构建脚本不再写资源(90 个文件 0 变化)、第二次 `cargo build` 1.13s fresh、三分支产物齐备、幂等与 fail-closed 通过;仍待验收的是「安装包内产物路径/sha256 比对」与「三个编辑器分支客户端加载」 | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d56284c31..d569e32a7 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 也会执行随包资源 staging(app 构建本身就需要这些资源),只跳过总号发布。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From 0cf536b67566a37708a866177ea389e8b6cb9ba0 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 21:35:56 +0800 Subject: [PATCH 24/60] =?UTF-8?q?=E6=81=A2=E5=A4=8D=20--no-bundle=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=BC=BA=E5=88=B6=E9=9A=8F=E5=8C=85=E8=B5=84?= =?UTF-8?q?=E6=BA=90=20staging=EF=BC=8C=E5=B9=B6=E5=90=8C=E6=AD=A5=20CI=20?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-release.mjs:回到「只有出包(非 --no-bundle)才执行 staging」,修复 Linux 上只编译的 release smoke 因强制 staging 去够 Windows 上游包而失败(保留 staging 与 cargo 共用同一 feature 集,以及未覆盖目标的自然跳过) - scripts/project-ci-workflow.test.ts:check:web 分组新增 bundled-resources:test 后的精确命令串断言同步 --- apps/ai-game-creator-shell/scripts/build-release.mjs | 3 +-- scripts/project-ci-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 632356a94..40dbeb520 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -469,9 +469,8 @@ export function runTauriBuild( const features = resolveEditorFeatures({ argv: args, target }); if (!args.includes('--no-bundle')) { stageRuntime(target); + stageBundled(target, { features }); } - // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging;只有总号发布是打包专属。 - stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 6f793634a..96935e9f6 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -696,7 +696,7 @@ describe('project CI workflow', () => { 'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe( - 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests', + 'npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe( 'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell', From 1099b2dad4e2fcc6e91730d30423256128593c02 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 29 Sep 2026 13:42:26 +0800 Subject: [PATCH 25/60] =?UTF-8?q?=E8=AE=B0=E5=BD=95=20M3=20Windows=20?= =?UTF-8?q?=E6=89=93=E5=8C=85=E4=B8=80=E8=87=B4=E6=80=A7=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E4=B8=8E=E4=B8=A4=E6=9D=A1=E6=8E=92=E9=9A=9C?= =?UTF-8?q?=E7=BB=8F=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑补「验收记录(2026-09-29)」:准备步骤连续三次复跑 32 个文件内容与 mtime 不变;NSIS 包内 plugins/ 与 resources/plugins 逐文件 sha256 完全一致;运行时三个相对路径与声明必需产物 17 项在包内全部命中 - 与迁移前两份对照(本机 2026-09-24 已安装包、合并基线 8f59c034f 的 build.rs 产物)逐项比对:路径集合与源码派生内容、Unity/Godot 产物一致 - 记录两条已定性差异:Cocos payload 只构建 windows-injection(导出面与迁移前相同)、MSVC 链接产物不可字节复现 - 记录新风险:准备步骤与应用构建共用 target///deps/cocos_editor_bridge.dll - 定性 CI 唯一红项 background_agent_runtime_can_write_memory_and_project_files 与本 PR 无关:合并基线同样失败,本机实测第二个 follow-up 请求 5.18s/4.87s 而预算 2s - pitfalls 新增 2026-09-29 条目:随包资源编译产物摘要不可复现与共用输出路径的核对口径 --- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 38 ++++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 6 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 50f65e448..e7893c506 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-28 已过 Windows 真机核心评审,剩打包一致性与客户端加载两项待验收) | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审;2026-09-29 完成打包一致性验收,客户端加载待有编辑器环境的机器;Cocos payload feature 集差异待裁决) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | @@ -40,11 +40,45 @@ ## 验收标准 - [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 -- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。(2026-09-29 验收:路径集合与源码派生内容、Unity/Godot 产物逐字节一致;Cocos payload 因声明只构建 `windows-injection` 而与迁移前不同,且 MSVC 链接产物本身不可字节复现,见下方验收记录,口径待裁决) - [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 - [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 - [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 +## 验收记录(2026-09-29,Windows 本机) + +### 打包一致性(验收标准第 2 条) + +准备步骤按发布入口同样的参数复跑(`prepareBundledResources({ target: 'x86_64-pc-windows-msvc', features: Set('unity-editor-execute','godot-editor-execute','cocos-editor-injection'), profile: 'release' })`):`cocos-bridge-build 已构建`、`unity-helper-publish` / `godot-extension-build 命中缓存`、`plugins 重新生成`;随后连续三次复跑,`resources/plugins` 的 32 个文件内容与 mtime 均不再变化(稳定产出)。 + +出包走同一发布入口(`runTauriBuild` + `--bundles nsis`),并临时把 `bundle.createUpdaterArtifacts` 置 false——本机没有 updater 签名私钥,只出安装包;产物 `target/x86_64-pc-windows-msvc/release/bundle/nsis/陶泥儿开发版_0.1.67_x64-setup.exe`(166452206 B,sha256 `04a0be8ab8f54d8f032ce86d3e5c7a99c1dd6e81f71d49b63486827b11a01940`)。`tauri.windows.conf.json` 里 `resources/plugins → plugins` 是目录级映射。 + +包内核对:7z 解包得 2108 个文件,包内 `plugins/` 的 32 个文件与准备步骤产出的 `resources/plugins` **逐文件 sha256 完全一致**;`editor_adapters.rs` 的三个运行时相对路径(`UNITY_ATTACH_HELPER_RELATIVE` / `GODOT_BRIDGE_PAYLOAD_RELATIVE` / `COCOS_BRIDGE_PAYLOAD_RELATIVE`)加上声明里的必需产物共 17 项在包内全部命中。 + +迁移前对照取两份:本机已安装的 2026-09-24 包(`%LOCALAPPDATA%\陶泥儿开发版\plugins`,迁移前产品产物),以及把 `src-tauri` 整体切回合并基线 `8f59c034f` 后在同一个工作树里跑 `cargo build --release --target x86_64-pc-windows-msvc --features=unity-editor-execute,godot-editor-execute,cocos-editor-injection` 得到的 `resources/plugins`。三份对照路径集合一致,源码派生内容(JS/HTML/JSON/license/notice)逐字节一致,Unity helper、Godot 扩展逐字节一致。 + +两处已定性的差异: + +1. **Cocos payload 的构建 feature 集不同(需裁决口径)**:迁移前由同一次应用构建产出(`windows-bootstrap` + `windows-injection`,345088 B);准备步骤按声明只构建 `windows-injection`(26112 B,见 2026-09-27 决策日志条)。两者导出面完全相同(`DllMain`、`cocos_editor_bridge_bootstrap_source`),差掉的是宿主侧 bootstrap 传输(`reqwest`/`tungstenite`/`inspector`),注入进程不使用;但按「内容摘要与迁移前逐项一致」的字面判据不成立。 +2. **MSVC 链接产物不可字节复现**:同一 source / feature / profile / target 连续构建的 payload 摘要不同(除 PE `TimeDateStamp` 外还有 22 字节 RSDS GUID 差异);.NET publish 相反是确定的(Unity helper 跨两次重新发布逐字节一致),Godot 因 `buildId` 早退未重链也保持逐字节一致。因此「摘要一致」只在源码派生物、.NET 产物与命中工具链内部缓存的产物上成立。 + +新发现的风险(本轮未修,不影响上述结论): + +- 准备步骤的 `cocos-bridge-build` 与同一次应用构建写同一个输出路径 `target///deps/cocos_editor_bridge.dll`(两个 feature 单元同名产物),准备步骤的候选查找可能取到另一单元刚写下的文件;本轮实测两者交替后 cargo 会多一次重链。建议让准备步骤在独立 target 目录构建,或与应用的 feature 集对齐后从同一单元取产物。 + +### 客户端编辑器分支(验收标准第 5 条) + +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。替代证据见上:三类组件都落在运行时解析的固定相对路径上,注入 payload 的导出面与迁移前一致。 + +### 顺带定性:CI 唯一红项与本 PR 无关 + +run 2980(HEAD `0cf536b6`)唯一失败项是 `tests::sessions::background_agent_runtime_can_write_memory_and_project_files`(`src-tauri/src/tests/sessions.rs:405`,第二个 provider follow-up 请求 `recv_timeout(2s)` 超时): + +- 本 PR 对这条路径零改动:`src/` 下只有 `main.rs`(`#[cfg(test)]` 引入 `package_layout` 单测)与 `agent/codex_cli.rs`(去掉 `codex_package_metadata` 测试模块)两处**测试编译期**改动;`sessions.rs` 与 agent runtime 与合并基线逐字节相同。 +- 把 `src-tauri` 整体切回合并基线 `8f59c034f` 后,同一条命令在本机失败在同一断言(`second llm request: Timeout`)。 +- 本机实测第二个 follow-up 请求耗时 **5.18s / 4.87s**(两次),而测试预算 2s:该断言在本机裕量不足。master run 2979(lane 2 shard 3)也因另一条并发用例失败,属同一类时间预算抖动。 +- 结论:既有测试时间预算问题,不在本 PR 内顺手修。 + ## 验证步骤(Windows 侧执行清单) 准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 48f3909a7..5da882ec3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -24,6 +24,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径 + +- **摘要不可复现**:同一 source / feature / profile / target 连续构建的 `cocos-editor-bridge` payload 摘要不同(除 PE `TimeDateStamp` 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对**源码派生物**(JS/HTML/JSON/license/notice,逐字节比对)、**.NET publish 产物**(Unity helper 跨两次重新发布逐字节一致)和**命中工具链内部缓存的产物**(Godot 走 `buildId` 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(`DllMain`、`cocos_editor_bridge_bootstrap_source`)+ 源码派生物摘要。 +- **共用输出路径**:准备步骤的 `cocos-bridge-build` 用 `cargo build -p cocos-editor-bridge --features windows-injection`,而应用构建带的是 `windows-bootstrap + windows-injection`(`cocos-editor-injection` 的闭包),两个单元写同一个 `target///deps/cocos_editor_bridge.dll`。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。 +- **验证方式**:`runTauriBuild`(`scripts/build-release.mjs`)+ `--bundles nsis`,再 `7z x` 解包比 `plugins/**`;准备步骤连续三次复跑要求 `resources/plugins` 的 32 个文件内容与 mtime 全不变。 + ## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 - **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 From 12d6ed822304ff15ac74b159cb7450b666b486cf Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 29 Sep 2026 13:55:06 +0800 Subject: [PATCH 26/60] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9A=84=E5=90=AF=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=8A=95=E5=BD=B1=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑「客户端编辑器分支」补本轮实测:安装包解出的客户端在 http://tauri.localhost/ 生产态正常起首页,包内插件与 Skill 包被读取 - 记录 CDP 主世界可读 list_agc_plugins / list_agc_skill_catalog 的自动化路径与 Godot 插件按项目类型可见的现役语义 - 记录「缺适配器 / 缺组件 / 编辑器没开」三类失败文案,供补做时分类记录 --- ...‹碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index e7893c506..9048bfd04 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -68,7 +68,14 @@ ### 客户端编辑器分支(验收标准第 5 条) -本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。替代证据见上:三类组件都落在运行时解析的固定相对路径上,注入 payload 的导出面与迁移前一致。 +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。 + +已完成的自动化前段(本轮实测,用安装包解出的客户端、不安装): + +- 从 NSIS 包 7z 解出后直接运行 `genarrative-ai-game-creator-shell.exe`:窗口落在 `http://tauri.localhost/`(生产态嵌入前端,不是 `devUrl`),首页正常渲染,最近项目与模板库可见——说明包内 `plugins/`、`skills`、模板资源都被正确读取(对照:用 `cargo build --release` 直接编出来的 exe 没有 `custom-protocol`,会去连 `127.0.0.1:3080` 并落到 chrome 错误页,不能拿它当打包客户端)。 +- CDP 主世界(`page.target().createCDPSession()` + `Runtime.evaluate`)可读只读投影:`list_agc_plugins` 返回 `agc-cocos-editor` / `agc-unity-editor`(`builtin=true`、`hasRuntime=true`、`adapter` 正确),`list_agc_skill_catalog` 返回 8 条 —— 插件的 JS 入口与 Skill 包都按声明进包。 +- `agc-godot-editor` 插件不在该列表里符合现役语义:`plugin_host.rs::plugin_matches_project` 只在当前项目是 Godot 工程(根或一层子目录有 `project.godot`)时才让它可见。 +- 三种编辑器分支的判据入口:`require_plugin_adapter` 缺适配器时报「当前客户端不支持 X 编辑器桥接」,适配器在 payload/helper 缺失时报各自缺组件文案(如 Godot「插件缺少原生 DLL 资源」),编辑器没开时报探测失败——补做时要按这三类分开记录。 ### 顺带定性:CI 唯一红项与本 PR 无关 From 0534e9c6780b45bd311fa95f4c4c45b49b594c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:20:37 +0800 Subject: [PATCH 27/60] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E4=BA=8B=E4=BB=B6=E6=8A=95=E5=BD=B1=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E9=80=80=E5=BD=B9=E6=9C=AC=E5=9C=B0=E9=98=9F?= =?UTF-8?q?=E5=88=97=E6=9C=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 conversation/directPendingTurns.ts:入队/移除两个纯函数,不判上限、不排期、不排序 - reducer 新增 queue.enqueued / queue.removed 分支,折成 pendingTurns 投影 - chip 文案派生搬到 pendingTurnChipLabel.ts,删除 chatComposerQueue.ts 整套本地队列实现 - controller 退役 queuedTurns / queueSequenceRef / completionPendingRef / busyBaselineTurnCountRef 与看门 counts effect;取消 chip 改调 remove_direct_project_pending_turn 按 typed 结果说话 - 埋点句柄按 clientTurnId 存表,回合终态按本轮开口条目身份结算 - turnBusy 改名 commandInFlight 并收窄成 IPC 在飞;忙态 = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空 - 提交改成等命令的入队结果:用户自己能改的入队失败保留草稿,其余按已交出处理 - 测试:队列投影单测、入队只发一次 IPC、IPC 在飞挡住第二次提交、满队保留草稿、取消已放行条目 - 文档:实施计划标注第 0–3 步落地并补第 3 步细则,ADR 的埋点与退役面口径改成落地后的写法 --- .../chat/DirectProjectChatView.tsx | 17 +- .../ComposerControls.tsx | 52 ++- .../DirectProjectComposer.tsx | 32 +- .../chatComposerQueue.ts | 99 ---- .../pendingTurnChipLabel.ts | 33 ++ .../useDirectProjectChatController.ts | 442 ++++++++---------- .../controller/useDirectProjectTurnStatus.ts | 48 +- .../chat/conversation/directPendingTurns.ts | 49 ++ .../chat/conversation/directThreadChat.ts | 58 ++- .../tests/appSurface/chat-composer.suite.ts | 437 ++++++++++------- .../tests/directProjectTurnStatus.test.ts | 50 +- .../tests/directThreadChat.test.ts | 128 +++++ ...命令入队化与待发消息队列归宿主-2026-09-24.md | 14 +- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 38 +- 14 files changed, 887 insertions(+), 610 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index d8e02903e..f1c00df6d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -125,8 +125,9 @@ export function DirectProjectChatView({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, + commandInFlight, composerNotice, directEntries, directTurnRunning, @@ -134,11 +135,10 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - queuedTurns, + pendingTurns, startInitialTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, } = chat; @@ -156,10 +156,11 @@ export function DirectProjectChatView({ }), [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 @@ -268,11 +269,13 @@ export function DirectProjectChatView({ projectPath={projectPath} attachmentNotice={attachmentNotice} attachmentsImporting={attachmentsImporting} - queuedTurns={queuedTurns} + pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} turnCancelling={turnCancelling} - onCancelQueuedTurn={cancelQueuedTurn} + onCancelPendingTurn={(clientTurnId) => + void cancelPendingTurn(clientTurnId) + } onCancelTurn={() => void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index dc4904ed0..3db8b8f3b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -3,7 +3,7 @@ * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 * * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), - * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ import { Check, @@ -30,8 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { queuedChatTurnLabel } from './chatComposerQueue'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, speechEventTranscript, @@ -41,6 +40,7 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; +import { pendingTurnChipLabel } from './pendingTurnChipLabel'; type ComposerAttachmentMenuProps = { disabled: boolean; @@ -151,38 +151,46 @@ export function ComposerAttachmentMenu({ ); } -/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +/** + * 待发消息 chip:还没放行的消息,按 FIFO 顺序展示,可单条取消。 + * + * 列表就是宿主事件流折出来的那一份(`DirectPendingTurn`),顺序与宿主认领队首的顺序一致; + * 这里不排序、不重排,也不自己决定放行。 + */ export function ComposerTurnQueue({ turns, assets, onCancel, }: { - turns: readonly QueuedChatTurn[]; + turns: readonly DirectPendingTurn[]; /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ assets: readonly GameCreationAppAssetManifestEntry[]; - onCancel: (id: string) => void; + onCancel: (clientTurnId: string) => void; }) { if (turns.length === 0) { return null; } return (
    - {turns.map((turn, index) => ( -
  1. - {index + 1} - - {queuedChatTurnLabel(turn, assets)} - - -
  2. - ))} + {turns.map((turn, index) => { + const label = pendingTurnChipLabel(turn, assets); + return ( +
  3. + + {index + 1} + + {label} + +
  4. + ); + })}
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index c92cd74d1..97e3ae34d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -23,8 +23,8 @@ import { } from '../../../../../features/project-workspace/ResourceReferencePicker'; import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; -import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -43,10 +43,10 @@ function draftAttachmentCount(draft: { } /** - * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。 * - * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 - * 仍然在聊天容器里,这里只负责把用户动作交回去。 + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、待发消息队列和附件的事实源 + * 仍然在聊天容器里(队列更上游是宿主),这里只负责把用户动作交回去。 */ export function DirectProjectComposer({ assets, @@ -54,11 +54,11 @@ export function DirectProjectComposer({ projectPath, attachmentNotice, attachmentsImporting, - queuedTurns, + pendingTurns, composerNotice, busy, turnCancelling, - onCancelQueuedTurn, + onCancelPendingTurn, onCancelTurn, onReferencePickerOpen, onSubmit, @@ -70,11 +70,11 @@ export function DirectProjectComposer({ attachmentNotice: string; /** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */ attachmentsImporting: boolean; - queuedTurns: QueuedChatTurn[]; + pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; busy: boolean; turnCancelling: boolean; - onCancelQueuedTurn: (id: string) => void; + onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; /** * 打开 `@` 选择器前刷新一次项目清单。 @@ -84,7 +84,14 @@ export function DirectProjectComposer({ * 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。 */ onReferencePickerOpen?: () => void; - onSubmit: (content: DirectCodexUserContentPart[]) => boolean; + /** + * 提交草稿:返回**是否把这条消息交出去了**。 + * + * `true` 只代表"草稿可以清"——入队成立、或已经交给权限确认流程(确认后会重跑同一份内容); + * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 + * 界面上凭空消失。 + */ + onSubmit: (content: DirectCodexUserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 @@ -180,7 +187,8 @@ export function DirectProjectComposer({ : modelReady; if (ready) { const content = composerRef.current?.getDraft().content ?? []; - if (onSubmit(content)) { + // 草稿清不清由命令的入队结果回答(可能是一次 IPC 往返):被拒的消息必须留下草稿。 + if (await onSubmit(content)) { composerRef.current?.clear(); } } @@ -192,9 +200,9 @@ export function DirectProjectComposer({ }} > item.id === turn.id)) { - return [...queue]; - } - return [...queue, turn]; -} - -/** 取队首(FIFO)。队列为空时 `next` 为 `null`,`rest` 保持空数组。 */ -export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): { - next: QueuedChatTurn | null; - rest: QueuedChatTurn[]; -} { - if (queue.length === 0) { - return { next: null, rest: [] }; - } - const [next, ...rest] = queue; - return { next: next ?? null, rest }; -} - -/** 单条取消:按 id 移除,其余项保持原有顺序。 */ -export function removeQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, -): QueuedChatTurn[] { - return queue.filter((item) => item.id !== id); -} - -export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean { - return queue.length >= MAX_QUEUED_CHAT_TURNS; -} - -export function chatQueueFullNotice(): string { - return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; -} - -/** - * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`), - * 再压成单行并限长。 - * - * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip - * 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。 - */ -export function queuedChatTurnLabel( - turn: QueuedChatTurn, - assets: readonly GameCreationAppAssetManifestEntry[], -): string { - const text = directCodexContentToPromptText( - turn.userItem.content, - resourceLabelResolver(assets), - ) - .trim() - .replace(/\s+/gu, ' '); - if (!text) return '未命名消息'; - return text.length > 24 ? `${text.slice(0, 24)}…` : text; -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts new file mode 100644 index 000000000..1a3e58a9d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts @@ -0,0 +1,33 @@ +/** + * 待发消息 chip 上显示的那一行字。 + * + * 文案与真实消息用**同一个派生**(`directCodexContentToPromptText`),再压成单行并限长:chip 与 + * 用户气泡逐字一致,不会一处展开 `@` 引用、另一处露出 `@asset:…` 这种内部 id。 + * + * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开。 + */ +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + directCodexContentToPromptText, + resourceLabelResolver, +} from '../../../../../features/project-workspace/resourceReferences'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; + +/** 单行 + 24 字上限:chip 是输入盒上的一条窄带,不展开整段需求。 */ +const PENDING_TURN_CHIP_MAX_CHARS = 24; + +export function pendingTurnChipLabel( + turn: DirectPendingTurn, + assets: readonly GameCreationAppAssetManifestEntry[], +): string { + const text = directCodexContentToPromptText( + turn.userItem.content, + resourceLabelResolver(assets), + ) + .trim() + .replace(/\s+/gu, ' '); + if (!text) return '未命名消息'; + return text.length > PENDING_TURN_CHIP_MAX_CHARS + ? `${text.slice(0, PENDING_TURN_CHIP_MAX_CHARS)}…` + : text; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 232197e32..429c71343 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -22,15 +22,6 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from '../../../../services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - type QueuedChatTurn, - removeQueuedChatTurn, -} from '../components/DirectProjectComposer/chatComposerQueue'; import { createDirectProjectTurnId, DIRECT_CODEX_AGENT_ID, @@ -48,6 +39,7 @@ import { toDirectCodexTurnAttachments, } from '../conversation/directCodexTurnAttachments'; import type { DirectCodexUserContentPart } from '../generated/DirectCodexUserContentPart'; +import type { DirectQueueRemovalOutcome } from '../generated/DirectQueueRemovalOutcome'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate'; @@ -106,9 +98,9 @@ export type DirectProjectChatControllerProps = { * C 本地: 前端自己造(忙态、入队失败提示、终止说明)——**不造用户消息** * ▼ * 前端 - * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) + * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live / pendingTurns) * ▼ - * useDirectProjectChatController 本地状态:localMessages / turnBusy / 队列 + * useDirectProjectChatController 本地状态:localMessages / IPC 在飞 / 附件与分页 * ▼ * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) * ▼ @@ -119,7 +111,8 @@ export type DirectProjectChatControllerProps = { * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** - * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);`queue.enqueued` / + * `queue.removed` 是**待发消息队列**的唯一判据(同一个 reducer 折成 chip);可回收事件被回收后靠 * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 @@ -127,26 +120,29 @@ export type DirectProjectChatControllerProps = { * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令入队化」), * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * - * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 + * 一次发送的时序(第 2 → 3 步之间就是「已交给宿主、宿主还没认领」的空窗:聊天区里没有这一轮的 * 任何条目,只有 composer 忙态与状态行): - * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在。 - * 2. `invoke('enqueue_direct_codex_turn')`:Rust 走完入队检查 → **入队**(排进待发消息队列)→ - * **立刻返回**。命令返回只说明入队成立;放行(登记占用 + `turn.started` + 离开队列)由宿主 + * 1. 按下发送:`commandInFlight=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在;草稿这时 + * 也先留着,清不清由命令的入队结果回答。 + * 2. `invoke('enqueue_direct_codex_turn')`:宿主走完入队检查 → **入队**(排进待发消息队列)→ + * **立刻返回**。命令返回只说明入队成立;放行(认领队首 + `turn.started` + `queue.removed`)由宿主 * 在队首就绪时自己完成,整轮的结果不再从这条通道回来,入队失败则返回结构化的 typed 错误。 - * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 - * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 + * 入队成立 → 清草稿;入队失败里"用户自己就能改"的那些 → **草稿原样留着**,另在聊天里补一条同级提示。 + * 3. `queue.enqueued`:chip 出现(队列在宿主,界面只是投影)。这一刻线程若正空闲,宿主已经在同一次 + * 认领里放行,`queue.removed{dispatched}` 与 `turn.started` 会在同一批 consume 里到达。 + * 4. `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 5. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 * 的用户气泡**第一次**出现在聊天区(发点在放行之后、起 codex 之前)。 - * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 6. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 + * 7. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 - * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**入队失败**时放掉忙态并出队(权限被拒那种 - * 「本轮从未发出但要继续出队」的路径也在这里收口),入队成立的那一轮交给第 3 / 7 步。 + * 8. **回合终态**(第 7 步的收口计数变化):按本轮身份结算埋点。**队列不在这里推进**——下一条什么时候 + * 走由宿主在占用释放时自己踢。 * - * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; - * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 - * 埋点句柄与分页 ref 只服务发送与展示;界面上的 - * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 + * 状态变量归属:reducer 的回合字段、待发消息投影、收口计数与 `history` / `live` 只由 + * `directThreadChat.ts` 写;本文件的 `commandInFlight`(唯一入口 `beginCommandInFlight` / + * `endCommandInFlight`,只覆盖 IPC 在飞)、`localMessages`、埋点句柄与分页 ref 只服务发送与展示; + * 界面上的「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ @@ -171,13 +167,10 @@ export function useDirectProjectChatController({ * 否则两次并发导入会拿着同一份草稿附件数各自放行,插进正文的芯片就超过上限。 */ const attachmentsPendingCountRef = useRef(0); - const [queuedTurns, setQueuedTurns] = useState([]); - const queuedTurnsRef = useRef([]); - queuedTurnsRef.current = queuedTurns; const [composerNotice, setComposerNotice] = useState(''); const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); - const [turnBusy, setTurnBusy] = useState(false); + const [commandInFlight, setCommandInFlight] = useState(false); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -189,113 +182,76 @@ export function useDirectProjectChatController({ const currentTurnRunning = directThread.turnRunning; const currentTurnStartedAt = directThread.turnStartedAt; const completedTurnCount = directThread.completedTurnCount; + /** + * 本轮开口条目的 canonical 身份:收口之后仍留着,用来把"刚结束的是哪一轮"讲清楚(结算埋点读它)。 + */ + const currentTurnUserItemId = directThread.state.turnUserItemId; + /** + * 待发消息:**投影自事件流**,界面不持有第二份队列。入队、取消、放行都只让这条列表跟着变。 + */ + const pendingTurns = directThread.state.pendingTurns; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); - const queueSequenceRef = useRef(0); const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; const directTurnRunningRef = useRef(currentTurnRunning); directTurnRunningRef.current = currentTurnRunning; - const turnBusyRef = useRef(turnBusy); - turnBusyRef.current = turnBusy; + const commandInFlightRef = useRef(commandInFlight); + commandInFlightRef.current = commandInFlight; /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ const handledCompletedTurnCountRef = useRef(0); /** - * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)—— - * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。 + * 每个已经交给宿主的回合的埋点句柄,按 `clientTurnId` 存。 + * + * 必须按身份存:队列在宿主侧,同一时刻可能有好几条待发消息(各自带着自己的 attempt id), + * 而成绩是**各自那一轮**收尾时在宿主侧入账的——单个槽位只会结算到最后一个,前面的全部静默丢掉。 + * 句柄活到本轮终态(见下面结算的那个 effect)。 */ - const busyBaselineTurnCountRef = useRef(0); - /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */ - const completedTurnCountRef = useRef(completedTurnCount); - completedTurnCountRef.current = completedTurnCount; - /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */ - const completionPendingRef = useRef(false); - /** - * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, - * 入队返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 - */ - const pendingRunAnalyticsRef = useRef<{ - clientTurnId: string; - runAnalytics: ReturnType; - } | null>(null); + const pendingRunAnalyticsRef = useRef( + new Map>(), + ); useEffect(() => { setAttachmentNotice(''); setComposerNotice(''); setStatusNotice(''); - setQueuedTurns([]); - queuedTurnsRef.current = []; setLocalMessages([]); - busyBaselineTurnCountRef.current = 0; setHistoryHasMore(false); historyOldestItemIdRef.current = null; handledCompletedTurnCountRef.current = 0; - completionPendingRef.current = false; - pendingRunAnalyticsRef.current = null; + pendingRunAnalyticsRef.current.clear(); }, [projectPath]); useEffect(() => { - if (!enabled || (!turnBusy && !currentTurnRunning)) return; + if (!enabled || (!commandInFlight && !currentTurnRunning)) return; const timer = window.setInterval(() => { void requestPlatformSessionRefresh().catch(() => { // 回合本身会用原始鉴权错误收尾;保活不能把它替换成无关的后台错误。 }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [enabled, turnBusy, currentTurnRunning]); + }, [enabled, commandInFlight, currentTurnRunning]); /** - * 宿主认领了这一轮:本地忙态交给原生真相。 - * - * 判据是**事件流里的回合边界**而不是命令的返回值:命令先返回、开始事件后到,中间那一段必须仍算 - * "命令在飞"——提前放掉,composer 会在两个回合之间开出一个能并发发送的空窗。 - * - * 两条判据任一条成立都算认领(后一条是兜底):开始事件已经落进 reducer(`turnRunning`), - * 或收口计数变过(这一轮在同一次 consume 里开始又结束,`turnRunning` 的上升沿观察不到)。 - */ - useEffect(() => { - if (!turnBusyRef.current) return; - if ( - currentTurnRunning || - completedTurnCount > busyBaselineTurnCountRef.current - ) { - endTurnBusy(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [turnBusy, currentTurnRunning, completedTurnCount]); - - /** - * 回合终态是队列放行与埋点结算的唯一出口(入队失败走命令那条路,见 `startTurn` 的收尾)。 + * 回合终态是**埋点结算**的唯一出口(入队失败走命令那条路,见 `runTurn` 的清理)。 * * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 - * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * 开始并结束,那时下降沿永远不会出现,这一轮的埋点就永远没人结算。 * - * TODO(发送队列):这条队列整体挪到宿主的待发消息队列(放行由 Thread Manager 自己驱动)。 + * 队列**不在这里推进**:待发消息归宿主,下一条什么时候走由 Thread Manager 在占用释放时自己踢。 */ useEffect(() => { if (!enabled) { handledCompletedTurnCountRef.current = completedTurnCount; - completionPendingRef.current = false; return; } - if (completedTurnCount !== handledCompletedTurnCountRef.current) { - handledCompletedTurnCountRef.current = completedTurnCount; - // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 - settlePendingRunAnalytics(); - completionPendingRef.current = true; - } - if ( - completionPendingRef.current && - !turnBusyRef.current && - !directTurnRunningRef.current - ) { - completionPendingRef.current = false; - dispatchNextQueuedTurn(); - } - // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。 + if (completedTurnCount === handledCompletedTurnCountRef.current) return; + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(currentTurnUserItemId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); + }, [enabled, completedTurnCount, currentTurnUserItemId]); useEffect(() => { if (!enabled || !projectPath) return; @@ -318,78 +274,83 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主放行这一轮(`turn.started` 落进 reducer) - * 或这一轮明确没成立时放掉。 + * 「IPC 在飞」的唯一起止点:按下发送时置上,这一次 `invoke` 落地(成功或失败)时放掉。 * - * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:入队化之后命令只等到入队就返回, - * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 + * 它只盖命令这一段:宿主认领这一轮之后忙态由原生真相与待发消息条数回答(`displayBusy` 是三者 + * 的并集,见 `useDirectProjectTurnStatus`)。 */ - function beginTurnBusy() { - turnBusyRef.current = true; - setTurnBusy(true); - busyBaselineTurnCountRef.current = completedTurnCountRef.current; + function beginCommandInFlight() { + commandInFlightRef.current = true; + setCommandInFlight(true); } - function endTurnBusy() { - turnBusyRef.current = false; - setTurnBusy(false); + function endCommandInFlight() { + commandInFlightRef.current = false; + setCommandInFlight(false); } /** * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 + * + * 认领靠身份:`userItemId` 是本轮开口条目的 canonical id,与 `clientTurnId` 一一对应 + * (`directCodexConversationMessageId`)。身份缺失(旧事件 / 没有开口条目)时退一步结算**最早**的 + * 那一条——放行严格按队首顺序,收口顺序就是入队顺序,这一层没有歧义。 */ - function settlePendingRunAnalytics() { + function settlePendingRunAnalytics(turnUserItemId: string) { const pending = pendingRunAnalyticsRef.current; - pendingRunAnalyticsRef.current = null; - pending?.runAnalytics.settle(); + if (pending.size === 0) return; + const matched = [...pending.keys()].find( + (clientTurnId) => + directCodexConversationMessageId(clientTurnId, 'user') === + turnUserItemId, + ); + const key = matched ?? pending.keys().next().value; + if (key === undefined) return; + const handle = pending.get(key); + pending.delete(key); + handle?.settle(); } function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } - function dispatchNextQueuedTurn() { - if (turnBusyRef.current || directTurnRunningRef.current) return; - const { next, rest } = dequeueChatTurn(queuedTurnsRef.current); - if (!next) return; - completionPendingRef.current = false; - queuedTurnsRef.current = rest; - setQueuedTurns(rest); - if (rest.length === 0) setComposerNotice(''); - startTurn({ - clientTurnId: next.clientTurnId, - userItem: next.userItem, - }); - } - - function enqueueTurn(input: { - clientTurnId: string; - userItem: DirectProjectTurnInput['userItem']; - }) { - if (isChatTurnQueueFull(queuedTurnsRef.current)) { - setComposerNotice(chatQueueFullNotice()); - return false; + /** + * 取消一条**还没放行**的待发消息:由宿主在临界区里判断"这条还在不在队里",界面只把结果说给用户。 + * + * chip 的消失**不靠这里**:宿主真的移除了就会发 `queue.removed{cancelled}`,界面按事件投影撤掉它。 + * 所以这里不改本地任何队列状态——它不是事实源,改了就多出第二份。 + */ + async function cancelPendingTurn(clientTurnId: string) { + const nextProjectPath = projectPath; + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + setComposerNotice('需要在 Tauri App 内运行,才能取消待发消息'); + return; + } + try { + const outcome = await invoke( + 'remove_direct_project_pending_turn', + { projectPath: nextProjectPath, clientTurnId }, + ); + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + outcome === 'notFound' + ? '这条待发消息已经不在队列里' + : // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 + outcome === 'alreadyDispatched' + ? '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」' + : '', + ); + } catch (error) { + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + `取消待发消息失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); } - queueSequenceRef.current += 1; - const turn = createQueuedChatTurn({ - id: `queued-chat-turn-${Date.now()}-${queueSequenceRef.current}`, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - createdAt: Date.now(), - }); - const next = enqueueChatTurn(queuedTurnsRef.current, turn); - queuedTurnsRef.current = next; - setQueuedTurns(next); - setComposerNotice('已加入发送队列,当前回合结束后自动发送'); - return true; - } - - function cancelQueuedTurn(id: string) { - const next = removeQueuedChatTurn(queuedTurnsRef.current, id); - queuedTurnsRef.current = next; - setQueuedTurns(next); - if (next.length === 0) setComposerNotice(''); } /** @@ -480,37 +441,23 @@ export function useDirectProjectChatController({ setComposerNotice(''); } - function submit(content: DirectCodexUserContentPart[]): boolean { + /** + * 提交草稿:**只有一条路**——交给宿主入队。 + * + * 本文件不再有"回合运行中就先排队"的分支:队列在宿主侧,忙不忙都由宿主自己判(队满、重复入队 + * 也由它回答)。返回值就是"草稿能不能清":入队失败里用户自己能改的那些返回 `false`,草稿留下。 + */ + async function submit( + content: DirectCodexUserContentPart[], + ): Promise { if (!hasMeaningfulDirectCodexContent(content)) return false; - if (turnBusyRef.current || currentTurnRunning) { - const clientTurnId = createDirectProjectTurnId(); - if ( - enqueueTurn({ - clientTurnId, - userItem: directCodexUserItemFromContent( - content, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }) - ) { - // 只清草稿:enqueueTurn 刚写入的「已加入发送队列」提示必须留给用户看到。 - clearDraftInput(); - return true; - } - return false; - } const clientTurnId = createDirectProjectTurnId(); const userItem = directCodexUserItemFromContent( content, directCodexConversationMessageId(clientTurnId, 'user'), ); - const turn: DirectProjectTurnInput = { - clientTurnId, - userItem, - }; clearPendingInput(); - startTurn(turn); - return true; + return startTurn({ clientTurnId, userItem }); } /** @@ -519,7 +466,7 @@ export function useDirectProjectChatController({ * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"入队窗口期聊天区没有这一 * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ - function startTurn(input: DirectProjectTurnInput) { + async function startTurn(input: DirectProjectTurnInput): Promise { const nextProjectPath = projectPath; if (!nextProjectPath || !projectId) { const message = resolveTauriInvoke() @@ -537,68 +484,52 @@ export function useDirectProjectChatController({ ), updatedAt: Date.now(), }); - return; + return true; } - beginTurnBusy(); - void (async () => { - let invoked = false; - // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 - let turnAccepted = false; - // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后: - // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 - let queueAdvance = false; - try { - onRuntimeError(''); - if (!input.directPolicyChecked) { - const allowed = await ensureConversationWriteAllowed({ - projectPath: nextProjectPath, - onConfirmed: () => { - startTurn(directCodexPolicyRetryInput(input)); - }, - }); - if (!allowed) { - // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), - // 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会 - // 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。 - queueAdvance = true; - return; - } - } - const invoke = resolveTauriInvoke(); - if (!invoke) return; - invoked = true; - turnAccepted = await runTurn(invoke, nextProjectPath, input); - } catch (error) { - // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, - // 已经乐观追加的用户消息也没有任何解释。 - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - error instanceof Error ? error.message : String(error), - ); - } - } finally { - setTurnCancelling(false); - // 权限门没放行时本轮从未发出:不刷新清单,也不推进队列,等确认后重跑。 - if (invoked) { - await refreshDirectManifest(nextProjectPath); - } - // 收尾分两种:入队成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` - // 时出队与结算,见上面两个 effect);入队失败的那些路径没有任何事件会来,只能在这里收口。 - if (!turnAccepted) { - // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 - // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 - endTurnBusy(); - // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 - // 入队失败时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 - if ( - queueAdvance || - (invoked && projectPathRef.current === nextProjectPath) - ) { - dispatchNextQueuedTurn(); - } + beginCommandInFlight(); + /** + * 草稿能不能清:入队成立、交给权限确认流程、认不出的宿主 / 环境失败都算已交出; + * 只有"用户自己就能改"的入队失败(宿主已经给了同级提示)才留着草稿,别让人重打一遍。 + */ + let draftSafe = false; + let invoked = false; + try { + onRuntimeError(''); + if (!input.directPolicyChecked) { + const allowed = await ensureConversationWriteAllowed({ + projectPath: nextProjectPath, + onConfirmed: () => { + void startTurn(directCodexPolicyRetryInput(input)); + }, + }); + if (!allowed) { + // 写权限门返回 false 有两种:策略拒绝(这一轮不会重跑)与交给确认流程(确认后重跑同一份 + // 内容)。两种都不该再让用户按一次发送——内容已经在重跑的入参里(或者用户已经被明确拒绝), + // 所以草稿照清,与入队化之前一致。 + return true; } } - })(); + const invoke = resolveTauriInvoke(); + if (!invoke) return true; + invoked = true; + draftSafe = await runTurn(invoke, nextProjectPath, input); + } catch (error) { + // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, + // 这一轮的任何说明都没有出口。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(error instanceof Error ? error.message : String(error)); + } + draftSafe = true; + } finally { + setTurnCancelling(false); + // 命令已经落地,IPC 在飞这一段结束;这一轮还在不在跑由事件流回答。 + endCommandInFlight(); + // 权限门没放行时本轮从未发出:不刷新清单,等确认后重跑。 + if (invoked) { + await refreshDirectManifest(nextProjectPath); + } + } + return draftSafe; } async function runTurn( @@ -607,18 +538,16 @@ export function useDirectProjectChatController({ input: DirectProjectTurnInput, ): Promise { // 命令返回 `Ok` 只说明**入队成立**:整轮怎么收场只由 `turn.completed` 事件回答。 - // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 - let turnAccepted = false; + // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 + let draftSafe = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 - pendingRunAnalyticsRef.current = { - clientTurnId: input.clientTurnId, - runAnalytics, - }; + // 按 `clientTurnId` 存:队列在宿主侧,同一时刻可能有好几条待发消息各自带着自己的身份。 + pendingRunAnalyticsRef.current.set(input.clientTurnId, runAnalytics); await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, @@ -626,7 +555,7 @@ export function useDirectProjectChatController({ analyticsAttemptId: runAnalytics.nextAttempt(), ...(input.creationType ? { creationType: input.creationType } : {}), }); - turnAccepted = true; + draftSafe = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 @@ -635,17 +564,15 @@ export function useDirectProjectChatController({ // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 - if ( - rejection && - pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId - ) { - pendingRunAnalyticsRef.current = null; + if (rejection) { + pendingRunAnalyticsRef.current.delete(input.clientTurnId); } if (rejection) { const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 - // 也不上报(用户自己就能改,上报只会变成噪声)。 + // 也不上报(用户自己就能改,上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条,界面上 + // 那条同级提示说的是"为什么",用户改一改就能直接再按发送。 if (projectPathRef.current === nextProjectPath) { onRuntimeError(''); appendLocalMessage({ @@ -661,7 +588,7 @@ export function useDirectProjectChatController({ return false; } } - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; @@ -673,7 +600,10 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; + // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 + // 用户以为没发出去、再按一次。 + draftSafe = true; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 @@ -693,7 +623,7 @@ export function useDirectProjectChatController({ // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 onRuntimeError(visibleMessage); } - return turnAccepted; + return draftSafe; } async function refreshDirectManifest(nextProjectPath: string) { @@ -710,7 +640,7 @@ export function useDirectProjectChatController({ if ( !invoke || !projectPath || - (!turnBusyRef.current && !currentTurnRunning) + (!commandInFlightRef.current && !currentTurnRunning) ) { onRuntimeError('当前没有正在运行的回合,无法终止。'); return; @@ -728,7 +658,7 @@ export function useDirectProjectChatController({ if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 - endTurnBusy(); + endCommandInFlight(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -850,7 +780,7 @@ export function useDirectProjectChatController({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, composerNotice, directEntries, @@ -858,12 +788,12 @@ export function useDirectProjectChatController({ directTurnStartedAt: currentTurnStartedAt, historyHasMore, loadEarlierHistory, + commandInFlight, localMessages, - queuedTurns, + pendingTurns, startInitialTurn: startTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index b884211e9..86632de3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import type { DirectPendingTurn } from '../conversation/directPendingTurns'; import type { DirectChatTurn, DirectChatTurnState, @@ -8,19 +9,21 @@ import type { /** * DirectProject「这一轮在跑吗」的唯一派生入口。 * - * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、 + * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`(今 `commandInFlight`)、 * 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义 * 一次讲清楚,组件只读这一个对象: * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 - * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → - * 宿主放行这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。入队化之后命令只 - * 等到入队就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 - * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: + * - `commandInFlight`:**本地真相**,而且只覆盖 **IPC 在飞**这一段:从按下发送到入队命令返回。 + * 入队化之后命令只等到入队就返回(放行由宿主自己完成),所以它不再是"这一轮还没被认领", + * 只是"这一次调用还没落地"。 + * - `pendingCount`:**队列真相**。宿主事件流折出来的待发消息条数(`queue.enqueued` / + * `queue.removed` 两条事件的投影);界面不持有第二份队列。 + * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,是三者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` - * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 - * 的交代(2026-09-24 口令)。 + * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」的 + * 交代(2026-09-24 口令)。 * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 @@ -31,41 +34,54 @@ import type { export type DirectProjectTurnStatus = { nativeRunning: boolean; commandInFlight: boolean; + pendingCount: number; displayBusy: boolean; latestTurnState: DirectChatTurnState | null; }; export function deriveDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { const nativeRunning = Boolean(turnRunning); - const commandInFlight = Boolean(turnBusy); + const inFlight = Boolean(commandInFlight); + const pendingCount = pendingTurns.length; const latest = turns.length > 0 ? turns[turns.length - 1] : null; return { nativeRunning, - commandInFlight, - displayBusy: nativeRunning || commandInFlight, + commandInFlight: inFlight, + pendingCount, + displayBusy: nativeRunning || inFlight || pendingCount > 0, latestTurnState: latest ? latest.state : null, }; } export function useDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { return useMemo( - () => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }), - [turnRunning, turnBusy, turns], + () => + deriveDirectProjectTurnStatus({ + turnRunning, + commandInFlight, + pendingTurns, + turns, + }), + [turnRunning, commandInFlight, pendingTurns, turns], ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts new file mode 100644 index 000000000..72fa4dfad --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts @@ -0,0 +1,49 @@ +/** + * 待发消息队列的**投影**。 + * + * 队列的事实源只有一个:运行态事件流(`queue.enqueued` / `queue.removed`),和回合边界一样。 + * 这里只有"把两条事件折成一份有序列表"的纯逻辑,不持有任何自己的状态、不做上限判断、不排期—— + * 队列归宿主(`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`), + * 界面只把宿主给的形状折成 chip。 + * + * 列表顺序就是事件的到达顺序(FIFO,宿主侧的认领也认队首),所以这里没有排序、没有身份表。 + */ +import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; + +/** 一条还没放行的待发消息。 */ +export type DirectPendingTurn = { + /** 这条待发消息的回合身份;放行之后同一轮的 `turn.started` / `turn.completed` 用它。 */ + clientTurnId: string; + /** canonical 用户条目:chip 文案由它派生(与聊天正文同一个派生)。 */ + userItem: DirectCodexUserItem; + /** 入队那一刻的宿主毫秒钟。 */ + at: number; +}; + +/** + * 队尾追加。同一个 `clientTurnId` 已经在队里时**原样返回入参**(引用相同),让调用方能把 + * "没有变化"直接折回原状态,不制造一次无意义的重渲染。 + */ +export function enqueuePendingTurn( + pending: readonly DirectPendingTurn[], + turn: DirectPendingTurn, +): readonly DirectPendingTurn[] { + if (pending.some((item) => item.clientTurnId === turn.clientTurnId)) { + return pending; + } + return [...pending, turn]; +} + +/** + * 按身份移除,取消与放行共用(原因只决定界面话术,不改这里的形状)。 + * + * 身份不在队里时同样原样返回:放行事件与 `turn.started` 同批到达,重复处理不能把后来的 + * 条目一起带走。 + */ +export function removePendingTurn( + pending: readonly DirectPendingTurn[], + clientTurnId: string, +): readonly DirectPendingTurn[] { + const next = pending.filter((item) => item.clientTurnId !== clientTurnId); + return next.length === pending.length ? pending : next; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 85d58327e..12a1d728b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -1,10 +1,11 @@ /** * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + - * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, - * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 - * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"与"还没放行的待发消息"。顺序 = 历史 + * 文件顺序 + 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个 + * 回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命 + * 周期事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 待发消息同样是事件折出来的列表(`queue.enqueued` / `queue.removed`),没有第二份队列状态。 * * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 @@ -17,6 +18,11 @@ import type { DirectThreadEvent } from '../generated/DirectThreadEvent'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from './directPendingTurns'; import { projectDirectThreadItem } from './directThreadItemProjection'; import { directTurnFailureItemId, @@ -70,7 +76,7 @@ export type DirectThreadChatState = { * 的先后决定。 * * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假, - * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。 + * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 `useDirectProjectTurnStatus` 的 `displayBusy` 出。 * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。 */ turnRunning: boolean; @@ -88,12 +94,22 @@ export type DirectThreadChatState = { /** * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * - * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 - * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(放行后 - * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, - * 批量到达也一样看得见。 + * 它是"回合完成"这个事实**唯一的计数**,只给上层结算埋点用(放行不归它管——队列在宿主侧, + * 由 Thread Manager 自己认领队首)。为什么要计数而不是看 `turnRunning` 的下降沿:一轮可能在 + * **同一次 consume** 里开始并结束(放行后立刻失败),那时 `turnRunning` 从头到尾没有被观察到 + * 真,下降沿永远不会来。计数是状态,批量到达也一样看得见。 */ completedTurnCount: number; + /** + * 还没放行的待发消息,保持入队顺序(FIFO)。 + * + * 它**不是**第二份队列状态:入队与移除都由 `queue.enqueued` / `queue.removed` 两条运行态事件 + * 折出来,与回合边界同一个事实源。因此新订阅者拿到 bootstrap 的 live-set 就等于拿到了此刻的 + * 队列(在队条目不可回收),另一个窗口、离开工作台再回来看到的都是同一份。 + * + * 上限、幂等、认领顺序全部在宿主侧,这里不判、不排期、不自己放行。 + */ + pendingTurns: readonly DirectPendingTurn[]; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -107,6 +123,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnEndedAt: 0, turnUserItemId: '', completedTurnCount: 0, + pendingTurns: [], history: [], live: [], }; @@ -427,6 +444,29 @@ export function reduceDirectThreadEvent( } return upsertLiveEntry(state, entry); } + case 'queue.enqueued': { + // 在队条目的身份就是 `clientTurnId`:宿主对同一条消息的重复入队返回同一次成功,这里也按同一 + // 身份折回原状态,不让同一句话在 chip 里出现两次。 + const pendingTurns = enqueuePendingTurn(state.pendingTurns, { + clientTurnId: event.clientTurnId, + userItem: event.userItem, + at: event.at, + }); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } + case 'queue.removed': { + // 取消与放行都只是"这条不再是待发消息":放行那一批里同时有 `turn.started`,用户气泡由宿主 + // 条目给出,chip 消失与气泡出现在同一批 consume 里,中间没有空窗。 + const pendingTurns = removePendingTurn( + state.pendingTurns, + event.clientTurnId, + ); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } case 'request': // 审批 / 提问只影响面板交互,不并入聊天条目。 return state; diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 51c3db79e..132940ace 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,14 +1,5 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import * as platformSession from '../../src/services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - queuedChatTurnLabel, - removeQueuedChatTurn, -} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts'; import { appendDictationText, resolveSpeechRecognitionCtor, @@ -18,11 +9,16 @@ import { VOICE_INPUT_UNSUPPORTED_MESSAGE, } from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts'; import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx'; +import { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from '../../src/view/project-development/chat/conversation/directPendingTurns.ts'; import { act, createGameCreationAppManifest, createProjectChatRuntimeHarness, - emptyProjectPolicy, expect, fireEvent, it, @@ -214,38 +210,111 @@ function installFakeSpeechRecognition(): { }; } -/** 排队回合只持 canonical user item;展示文案由它派生。 */ -function queuedTurn( - id: string, +/** + * 一条待发消息的投影形状:事件带 canonical user item,chip 文案由它派生。 + * + * 身份换算与生产同源(`direct-codex:{clientTurnId}:{role}`),不另造一套。 + */ +function pendingTurn( clientTurnId: string, text: string, - createdAt: number, -) { - return createQueuedChatTurn({ - id, + at = 1, +): DirectPendingTurn { + return { clientTurnId, userItem: directCodexUserItemFromContent( [{ type: 'input_text', text }], - `${clientTurnId}:user`, + `direct-codex:${clientTurnId}:user`, ), - createdAt, - }); + at, + }; +} + +/** + * 宿主的队列动作在测试里的替身:入队、取消、回合收尾后放行队首。 + * + * 队列归宿主之后界面看到的每一步都来自事件——入队发 `queue.enqueued`,线程空闲时在同一次认领里 + * 追加 `queue.removed{dispatched}` 与 `turn.started`,取消发 `queue.removed{cancelled}`。 + * 这里只做 Thread Manager 会做的那几件事,顺序就是入队顺序(认领永远认队首)。 + */ +function hostQueue( + harness: ReturnType, + /** 打开面板时线程上是否已经有一条没收口的回合(恢复出来的那一轮)。 */ + { running: alreadyRunning = false }: { running?: boolean } = {}, +) { + const queued: Array<{ clientTurnId: string; userItem: unknown }> = []; + let running = alreadyRunning; + const dispatchHeadIfIdle = () => { + if (running) return; + const head = queued.shift(); + if (!head) return; + running = true; + harness.emitDirectThreadEvents( + { + type: 'queue.removed', + clientTurnId: head.clientTurnId, + reason: 'dispatched', + at: 2, + }, + { + type: 'turn.started', + at: 2, + userItemId: `direct-codex:${head.clientTurnId}:user`, + }, + ); + }; + return { + /** 入队命令的宿主实现:入队 → 线程空闲就顺手放行。 */ + enqueue(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + queued.push({ clientTurnId, userItem: args?.userItem }); + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId, + userItem: args?.userItem, + at: 1, + }); + dispatchHeadIfIdle(); + return null; + }, + /** 取消命令的宿主实现:真的在队里才移除,并广播原因。 */ + cancel(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + const index = queued.findIndex( + (item) => item.clientTurnId === clientTurnId, + ); + if (index < 0) return running ? 'alreadyDispatched' : 'notFound'; + queued.splice(index, 1); + harness.emitDirectThreadEvents({ + type: 'queue.removed', + clientTurnId, + reason: 'cancelled', + at: 3, + }); + return 'removed'; + }, + /** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */ + complete(status = 'completed') { + running = false; + harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 }); + dispatchHeadIfIdle(); + }, + }; } export function registerChatComposerControlTests() { - it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { - const turn = createQueuedChatTurn({ - id: 'turn-ref', + it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { + const turn: DirectPendingTurn = { clientTurnId: 'client-ref', userItem: directCodexUserItemFromContent( [ { type: 'input_text', text: '用这张图改一下' }, { type: 'agc_resource_reference', resourceId: 'asset:hero' }, ], - 'client-ref:user', + 'direct-codex:client-ref:user', ), - createdAt: 1, - }); + at: 1, + }; const manifestAssets = [ { id: 'asset:hero', @@ -258,57 +327,77 @@ export function registerChatComposerControlTests() { // chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`; // 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。 - expect(queuedChatTurnLabel(turn, manifestAssets)).toBe( + expect(pendingTurnChipLabel(turn, manifestAssets)).toBe( '用这张图改一下 @hero', ); + // 空内容也给一行可显示的字,不让 chip 变成一条空白带。 + expect(pendingTurnChipLabel(pendingTurn('client-empty', ''), [])).toBe( + '未命名消息', + ); }); - it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { - const first = queuedTurn('turn-1', 'client-1', '第一条', 1); - const second = queuedTurn('turn-2', 'client-2', '第二条', 2); - const third = queuedTurn('turn-3', 'client-3', '第三条', 3); + it('folds the pending queue from events: FIFO append, identity removal, idempotent enqueue', () => { + let pending: readonly DirectPendingTurn[] = []; + pending = enqueuePendingTurn(pending, pendingTurn('client-1', '第一条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-2', '第二条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-3', '第三条')); - let queue = enqueueChatTurn([], first); - queue = enqueueChatTurn(queue, second); - queue = enqueueChatTurn(queue, third); - // 同一条消息重复入队不得变成两次发送。 - expect(enqueueChatTurn(queue, third)).toHaveLength(3); - - // FIFO:先入先出,不丢、不乱序。 - const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.clientTurnId).toBe('client-1'); - expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe( + // 顺序就是事件顺序(宿主认领队首,所以界面不许自己排)。 + expect(pending.map((turn) => pendingTurnChipLabel(turn, []))).toEqual([ '第一条', - ); - expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([ '第二条', '第三条', ]); - // 单条取消只移除那一条,顺序不变。 - expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => - queuedChatTurnLabel(turn, []), - ), - ).toEqual(['第三条']); - expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); + // 同一条消息重复入队不产生第二个 chip:宿主的入队是幂等的,投影这一层也按身份折回原状态 + // (引用不变,调用方能直接跳过重渲染)。 + expect(enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'))).toBe( + pending, + ); - // 空队列出队不报错、也不产生"幽灵消息"。 - expect(dequeueChatTurn([]).next).toBeNull(); + // 取消与放行共用同一条移除:按身份删,其余顺序不变。 + expect( + removePendingTurn(pending, 'client-2').map((turn) => + pendingTurnChipLabel(turn, []), + ), + ).toEqual(['第一条', '第三条']); + // 身份不在队里时原样返回(放行事件与 `turn.started` 同批到达、或事件被重放)。 + expect(removePendingTurn(pending, 'client-missing')).toBe(pending); }); - it('reports a readable reason instead of silently dropping a full queue', () => { - let queue: ReturnType[] = []; - for (let index = 0; index < 5; index += 1) { - queue = enqueueChatTurn( - queue, - queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), - ); - } - expect(isChatTurnQueueFull(queue)).toBe(true); - expect(chatQueueFullNotice()).toContain('队列已满'); - expect(chatQueueFullNotice()).toContain('5'); - expect(removeQueuedChatTurn(queue, 'turn-0')).toHaveLength(4); + it('keeps the draft and explains why when the host rejects the enqueue as full', async () => { + // 队列上限只在宿主一处(`MAX_PENDING_DIRECT_TURNS`):界面不再自己数一份、不再自己造一句 + // 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。 + const { invoke, surface } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: () => { + throw { + error: { type: 'queueFull', limit: 5 }, + message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '第六条消息'); + + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + ), + ).not.toBeNull(); + }); + // 草稿没丢:这条消息还在输入盒里,用户改一改或等一会儿就能重发。 + expect(composer.textContent ?? '').toContain('第六条消息'); + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + // 入队没成立就是没成立:只发了一次命令,也不会有回合事件来结算埋点。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); it('degrades the voice input button with a readable hint when speech recognition is missing', async () => { @@ -478,14 +567,14 @@ export function registerChatComposerControlTests() { }); }); - it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { + it('enqueues messages sent while a turn runs, cancels one chip, and never sends the same message twice', async () => { + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface({ - // 命令只入队(放行由 Thread Manager 自己完成),随后立刻返回。 - enqueue_direct_codex_turn: () => { - harness.emitDirectThreadEvents({ type: 'turn.started' }); - return Promise.resolve(null); - }, + // 宿主替身:入队就发 `queue.enqueued`,线程空闲时同一次认领里放行队首。 + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), }); + host = hostQueue(harness); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); await waitFor(() => { @@ -494,16 +583,14 @@ export function registerChatComposerControlTests() { expectDirectTurnWithText('第一条消息'), ); }); - // 回合运行中:发送钮位置变成终止钮。 + // 第一条被立刻放行:回合运行中,发送钮位置变成终止钮,队列里没有它(它不是待发消息)。 expect( await within(surface).findByRole('button', { name: '终止' }), ).not.toBeNull(); + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); await setComposerText(composer, '第二条消息'); submitComposerForm(composer); - await waitFor(() => { - expect(within(surface).getByText('第二条消息')).not.toBeNull(); - }); await setComposerText(composer, '第三条消息'); submitComposerForm(composer); @@ -520,42 +607,73 @@ export function registerChatComposerControlTests() { expect.stringContaining('第三条消息'), ]); - // 单条取消:只移除第二条,第三条保留。 + // 单条取消:命令交给宿主判,界面按 `queue.removed{cancelled}` 撤掉那一条,顺序不变。 fireEvent.click( - within(queue).getByRole('button', { name: '取消排队消息 第二条消息' }), + within(queue).getByRole('button', { name: '取消待发消息 第二条消息' }), ); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); + }); await waitFor(() => { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + expect(queue.textContent ?? '').toContain('第三条消息'); - // 队列放行听的是**回合终态**,不是命令返回:入队之后命令早就回来了。 + // 回合收尾 → 宿主放行剩下的那条:chip 消失与它自己的回合开始在同一批事件里。 act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('第三条消息'), - ); - }); - // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 - const sentTexts = invoke.mock.calls - .filter(([command]) => command === 'enqueue_direct_codex_turn') - .map(([, args]) => directTurnInputText(args)); - expect(sentTexts).toEqual(['第一条消息', '第三条消息']); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); + host?.complete(); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); }); + + // 关键不变式:排队的那条**没有**再走一次发送命令,入队就是一次性动作。 + expect( + invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => directTurnInputText(args)), + ).toEqual(['第一条消息', '第二条消息', '第三条消息']); + }); + + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { + // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 入队成立但并不放行(线程上已经有别的回合在跑):chip 留着,取消才有东西可取消。 + enqueue_direct_codex_turn: (args) => { + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId: String(args?.clientTurnId ?? ''), + userItem: args?.userItem, + at: 1, + }); + return null; + }, + remove_direct_project_pending_turn: () => 'alreadyDispatched', + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '还没跑的这条'); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('还没跑的这条')).not.toBeNull(); + + fireEvent.click( + within(queue).getByRole('button', { name: '取消待发消息 还没跑的这条' }), + ); + await waitFor(() => { + expect( + within(surface).getByText( + '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」', + ), + ).not.toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); }); it('keeps the accept window silent in the chat and busy in the composer', async () => { @@ -748,76 +866,55 @@ export function registerChatComposerControlTests() { }); }); - it('keeps the next queued turn busy when the write gate refuses the running one', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; - const deferredPolicies: Array<(value: unknown) => void> = []; - let policyAllowsWrite = false; + it('does not fire a second enqueue while the first one is still in flight', async () => { + // 草稿要等命令的入队结果才知道能不能清,所以这一段提交是"在飞"的。这期间第二次提交必须被挡住: + // 同一份草稿并发出两条就是两条消息(宿主按 `clientTurnId` 幂等也救不了——第二次是新身份)。 + const pending: Array<{ resolve: (value: null) => void }> = []; const { invoke, surface } = await openDirectCodexSurface({ - read_project_permission_policy: () => { - if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy()); - return new Promise((resolve) => { - deferredPolicies.push(resolve); - }); - }, enqueue_direct_codex_turn: () => - new Promise((resolve) => { + new Promise((resolve) => { pending.push({ resolve }); }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '被拒的那条'); - - // 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。 - await setComposerText(composer, '后面那条'); - submitComposerForm(composer); - const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('后面那条')).not.toBeNull(); - - // 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑, - // 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。 - policyAllowsWrite = true; - await act(async () => { - for (const resolve of deferredPolicies.splice(0)) { - resolve({ - path: '.agent/policy.json', - policy: { - deniedCommands: [], - confirmCommands: ['conversation.write'], - }, - }); - } - }); - + await submitDirectTurn(surface, composer, '在飞的那条'); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('后面那条'), - ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); - // 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。 - expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - expect( - within(surface).getByRole('button', { name: '终止' }), - ).not.toBeNull(); + submitComposerForm(composer); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + + // 命令落地(入队成立):草稿清掉、IPC 在飞这一段结束,用户能接着发下一条。 await act(async () => { - pending[0]?.resolve('回复'); + pending[0]?.resolve(null); + }); + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); }); }); - it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; + it('queues the next message on the host while a turn is already running', async () => { + // 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。 + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface( - { - enqueue_direct_codex_turn: () => - new Promise((resolve) => { - pending.push({ resolve }); - }), - }, + { enqueue_direct_codex_turn: (args) => host?.enqueue(args) }, (directHarness) => { directHarness.emitDirectThreadEvents({ type: 'turn.started' }); }, ); + // 线程上已经有回合在跑:入队成立但不放行,chip 就留着。 + host = hostQueue(harness, { running: true }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); expect( @@ -825,37 +922,35 @@ export function registerChatComposerControlTests() { ).not.toBeNull(); expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - await setComposerText(composer, '恢复后排队的消息'); + // 回合运行中发送钮位置是终止钮,所以这里走表单提交(Enter 也是这条路)。 + await setComposerText(composer, '排在后面的消息'); submitComposerForm(composer); const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'enqueue_direct_codex_turn', - ), - ).toHaveLength(0); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('恢复后排队的消息'), - ); + expect(within(queue).getByText('排在后面的消息')).not.toBeNull(); }); + // 入队就是一次性动作:这条消息只有这一次命令,没有"先存本地、之后由这个窗口再发一次"。 expect( invoke.mock.calls.filter( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); - await act(async () => { - pending[0]?.resolve('排队回合回复'); + // 宿主在回合收尾后自己放行队首:chip 消失,那一轮的回合开始事件同批到达。 + act(() => { + host?.complete(); }); + await waitFor(() => { + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); }); it('terminates the running turn and returns the composer to the idle state', async () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts index 9c8e210b1..779b70314 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus'; +import type { DirectPendingTurn } from '../src/view/project-development/chat/conversation/directPendingTurns'; import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation'; const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ @@ -13,62 +14,91 @@ const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ endedAt: 0, }); +/** 待发消息:这里只关心条数,内容与 chip 文案无关。 */ +const pending = (clientTurnId: string): DirectPendingTurn => ({ + clientTurnId, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text: clientTurnId }], + }, + at: 1, +}); + describe('DirectProject 回合状态派生', () => { - it('displayBusy 是原生真相与本地命令在飞的并集', () => { + it('displayBusy 是原生真相、IPC 在飞与待发消息条数的并集', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], + turns: [], + }).displayBusy, + ).toBe(true); + // 队列非空也是忙:还有一条待发消息在宿主手里,这时不能假装空闲。 + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + commandInFlight: false, + pendingTurns: [pending('client-1')], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(false); }); - it('两个来源各自独立暴露,不被并集吃掉', () => { + it('三个来源各自独立暴露,不被并集吃掉', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: true, + commandInFlight: true, + pendingTurns: [pending('client-1'), pending('client-2')], turns: [], }); expect(status.nativeRunning).toBe(true); expect(status.commandInFlight).toBe(true); + expect(status.pendingCount).toBe(2); }); it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [turn('u1', 'finished'), turn('u2', 'running')], }).latestTurnState, ).toBe('running'); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).latestTurnState, ).toBeNull(); }); - it('本地命令在飞不等于原生在跑', () => { + it('IPC 在飞不等于原生在跑', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], turns: [turn('u1', 'finished')], }); expect(status.nativeRunning).toBe(false); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 4b385780e..ceb68ede4 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -284,6 +284,134 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + describe('待发消息队列投影(queue.enqueued / queue.removed)', () => { + /** 一条入队事件:canonical 用户条目由宿主给,界面只读它派生 chip。 */ + function enqueued(clientTurnId: string, text: string, at = 1_000) { + return event({ + type: 'queue.enqueued', + clientTurnId, + at, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text }], + }, + } as Partial); + } + + it('按事件顺序折成队列,取消与放行都只按身份移除', () => { + const queued = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + enqueued('client-3', '第三条'), + ]); + expect(queued.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + 'client-3', + ]); + + // 取消中间那条:其余顺序不变。 + const cancelled = reduceDirectThreadEvents(queued, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-2', + reason: 'cancelled', + }), + ]); + expect(cancelled.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-3', + ]); + + // 放行走的是同一条移除:chip 消失,回合边界由同批的 `turn.started` 给出。 + const dispatched = reduceDirectThreadEvents(cancelled, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'dispatched', + }), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + ]); + expect(dispatched.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-3', + ]); + expect(dispatched.turnRunning).toBe(true); + }); + + it('重复入队与重复移除都不改动状态引用(入队幂等、事件可能被重放)', () => { + const once = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + ]); + const twice = reduceDirectThreadEvents(once, [ + enqueued('client-1', '第一条'), + ]); + expect(twice).toBe(once); + + const removed = reduceDirectThreadEvents(twice, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removed.pendingTurns).toHaveLength(0); + const removedAgain = reduceDirectThreadEvents(removed, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removedAgain).toBe(removed); + }); + + it('队列不随回合收口消失:在队的那几条仍然是待发消息', () => { + // 一轮可以在同一次 consume 里开始又结束(放行后立刻失败),队列的推进归宿主: + // reducer 只按 `queue.removed` 撤 chip,绝不因为回合收口就自己清空。 + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + event({ type: 'turn.completed', status: 'failed', at: 2_100 }), + ]); + expect(state.turnRunning).toBe(false); + expect(state.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + ]); + }); + + it('bootstrap 把 bootstrap 那一刻的在队条目交给新订阅者', () => { + // 宿主在队条目不可回收,所以 live-set bootstrap 天然带着当前队列:另一个窗口、离开工作台 + // 再回来,看到的都是同一份(这里用 bootstrap 路径而不是实时 consume 钉住)。 + const bootstrap = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + ], + } as never, + ); + expect( + bootstrap.pendingTurns.map((turn) => turn.userItem.content), + ).toEqual([ + [{ type: 'input_text', text: '第一条' }], + [{ type: 'input_text', text: '第二条' }], + ]); + }); + }); + describe('失败终态(turn.completed 带 failure 载荷)', () => { it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index d4e741723..67afbd699 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -106,16 +106,20 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 - **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 - **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 - `turnBusyRef`/`beginTurnBusy`/`endTurnBusy`、`queueSequenceRef`、`queuedTurns*`、排队条目那部分 `pendingRunAnalyticsRef` - 与 `chatComposerQueue.ts` 的队列逻辑整体退役;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞; - 埋点句柄改由宿主在放行时开。 + `queueSequenceRef`、`queuedTurns*` 与 `chatComposerQueue.ts` 整体退役(含它的上限常量与"队列已满"文案—— + 上限只留宿主一处);`turnBusy`/`beginTurnBusy`/`endTurnBusy` 改名 `commandInFlight` 并收窄成 IPC 在飞; + `directProjectTurnStatus` 的忙态改成「原生在跑 ∨ IPC 在飞 ∨ 待发消息非空」。 + 队列投影落在 `chat/conversation/directPendingTurns.ts`,chip 文案落在 + `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts`。 - **词表切换是一次性跨文档动作**:仓库里「接单/拒单」共 384 处,并非全属同一个域 (`features/agent-runtime` 的"拒单文案"属另一个域,改成"请求被拒";`单测` 这类是假阳性)。 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 -- **埋点**:attempt id 只用于宿主内存里的候选配对与前端结算(同 `session_id` 的 `Settle` 才提交), - 前端不再需要为排队条目提前持有它;平台会话代际翻转的 `discard` 判定仍是渲染侧职责,落地时逐条核对。 +- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主(放行不重算,与其它入队检查产物一样 + 存进队列条目),但前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表, + 回合终态按本轮开口条目的 canonical 身份认领结算,入队失败的那一条直接删掉不结算。 + 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 ## 明确不做 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 150ef125a..2416050e7 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,11 +2,22 @@ 更新时间:`2026-09-24` -状态:**待实施**(设计已定稿,尚未开工) +状态:**实施中**(第 0–3 步已落地;第 4、5 步待做) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 +## 落地进度 + +| 步骤 | 状态 | 落地说明 | +| --- | --- | --- | +| 第 0 步 词表切换 | 已落地 | `rg "接单\|拒单"` 只剩 `direct_runtime/mod.rs` 对旧 ADR 文件名的引用(链接完整性,故意保留)与 `codex_app_server/mod.rs` 的一处假阳性 | +| 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | +| 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | +| 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | +| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 5 步 守卫清理 | 待做 | | + ## 第 0 步:词表切换(与代码同批,不单独提交) 「接单 / 拒单」退役,改成「入队 / 入队失败 / 放行」。逐处按角色改,**不做字面替换**: @@ -88,6 +99,26 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem ## 第 3 步:前端收口 +### 第 3 步的落地细则 + +- 队列投影是新文件 `chat/conversation/directPendingTurns.ts`:只做 `enqueuePendingTurn` / + `removePendingTurn` 两个纯函数,**不判上限、不排期、不排序**;上限与认领顺序只在宿主。 + reducer 的 `queue.enqueued` / `queue.removed` 两个分支是它唯一的调用方。 +- chip 文案派生搬到 `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts` + (`pendingTurnChipLabel`),与 `chatComposerQueue.ts` 一起把"队列在本地"的最后一份实现删掉。 +- **草稿清不清由命令的入队结果回答**:`onSubmit` 改成返回 `Promise`,composer 只在 + `true` 时清草稿。返回 `false` 的口子是"用户自己就能改的入队失败"(队列已满、参数无效这类)—— + 宿主已经给了同级提示,草稿再没了就等于让用户重打一遍。写权限门让路给确认流程时返回 `true` + (内容已经在重跑的入参里),与入队化之前一致。 +- **埋点句柄按 `clientTurnId` 存成一张表**(`pendingRunAnalyticsRef`),回合终态按本轮开口条目的 + canonical 身份(`turn.completed.userItemId` ↔ `directCodexConversationMessageId(clientTurnId,'user')`) + 认领结算;身份缺失时退回结算最早的那一条(放行严格按队首顺序,收口顺序就是入队顺序)。 + 入队失败的那一轮直接把句柄删掉,不结算。 +- `displayBusy` = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空;`commandInFlight` 收窄成"IPC 在飞"。 +- 取消 chip 调 `remove_direct_project_pending_turn`,按 typed 结果 `removed / alreadyDispatched / + notFound` 说清楚;chip 的撤除仍然只认 `queue.removed` 事件(界面不改本地队列)。 + + 退役: - `chatComposerQueue.ts` 的 `enqueueChatTurn` / `dequeueChatTurn` / `removeQueuedChatTurn` / `isChatTurnQueueFull` / @@ -104,8 +135,9 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - 忙态 = 事件投影 + 「队列非空」指示;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞。 - 写权限门 `ensureConversationWriteAllowed` 留在入队之前(确认框必须在用户在场时弹)。 -验收:`tests/directThreadChat.test.ts` 补队列事件投影用例;`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / -放行三组用例改成新语义;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 +验收:`tests/directThreadChat.test.ts` 补队列事件投影用例(顺序、幂等、按身份移除、bootstrap 带出在队条目、 +回合收口不清队列);`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / 放行四组用例改成新语义, +并新增「入队只发一次 IPC」「IPC 在飞时挡住第二次提交」两条;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 ## 第 4 步:CLI 与夹具退役 From 25f3e416238beab87499b2327d5dab1683ce9506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:25:38 +0800 Subject: [PATCH 28/60] =?UTF-8?q?CLI=20=E4=B8=8E=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E9=80=80=E5=BD=B9=EF=BC=9ADirectProject=20=E5=8F=AA=E5=89=A9?= =?UTF-8?q?=E5=85=A5=E9=98=9F=E4=B8=80=E4=B8=AA=E5=91=BD=E4=BB=A4=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.rs 删 CliCommand::DirectCodexChat 变体、project_path_mut 分支、--direct-codex-chat 解析与派发 - direct_runtime/mod.rs 删 run_direct_game_creator_turn_at 与 run_direct_game_creator_turn_at_with_creation_type - 删 scripts/direct-execution-production-fixture.mjs(CLI 唯一消费者) - user_input.rs 模块注释改成"唯一的命令入口",不再承诺 CLI 分工 - 顺带删掉只剩测试在用的 direct_turn_error_boundary_text,判据只剩 direct_turn_enqueue_failure 一处 - 三条命令边界测试改打 direct_turn_enqueue_failure(...).message,覆盖不变 - 文档:09-22 里程碑把 --direct-codex-chat 从"范围外(保留)"改成退役项,两份 Direct 技术方案的 CLI 承诺删掉, 09-23 ADR 的"CLI 保持 await"标为口径反转,decision-log 追加口径修正,实施计划标注第 4 步落地 --- .../direct-execution-production-fixture.mjs | 975 ------------------ .../src-tauri/src/agent/direct_runtime/mod.rs | 61 +- .../src/agent/direct_runtime/user_input.rs | 5 +- .../src-tauri/src/cli.rs | 56 - ...�ADR】DirectProject命令接单化-2026-09-23.md | 4 +- ...¡¹目对话斜杠命令与终端swarm chat入口-2026-09-22.md | 7 +- .../shared-memory/decision-log.md | 11 +- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 2 +- ...¡ˆ】DirectProject本轮附件路径映射-2026-08-31.md | 3 +- ...术方案】Direct回合行为审计账本-2026-08-31.md | 6 +- 10 files changed, 33 insertions(+), 1097 deletions(-) delete mode 100644 apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs diff --git a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs b/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs deleted file mode 100644 index 1777dd8c9..000000000 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ /dev/null @@ -1,975 +0,0 @@ -// Real AGC CLI -> bundled app-server -> loopback Responses/MCP fixtures. -// No account credentials, installed AppData, or paid Provider are used. -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..', -); -const args = process.argv.slice(2); -const option = (name) => { - const i = args.indexOf(name); - return i < 0 ? undefined : args[i + 1]; -}; -const executable = option('--agc-exe'); -assert( - executable && path.isAbsolute(executable), - 'pass --agc-exe with the newly built debug AGC binary', -); -const codex = path.resolve( - option('--codex-exe') ?? - path.join( - repo, - 'node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe', - ), -); -const cases = ( - option('--cases') ?? 'completed,passes,mcp,mcp-write,native,deadline' -).split(','); -const root = await fs.mkdtemp( - path.join(os.tmpdir(), 'agc-execution-production-'), -); -console.log(JSON.stringify({ evidenceRoot: root })); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const hash = (value) => createHash('sha256').update(value).digest('hex'); -const exists = async (file) => - fs.stat(file).then( - () => true, - () => false, - ); -const quote = (text) => - process.platform === 'win32' - ? "'" + text.replaceAll("'", "''") + "'" - : "'" + text.replaceAll("'", "'\\''") + "'"; -const nodeCommand = (source) => - (process.platform === 'win32' ? '& ' : '') + - quote(process.execPath) + - ' -e ' + - quote( - process.platform === 'win32' - ? // Windows PowerShell 5 removes nested double quotes from native argv. - "eval(Buffer.from('" + - Buffer.from(source).toString('base64') + - "','base64').toString())" - : source, - ); -const call = (id, name, arguments_, namespace) => ({ - type: 'function_call', - id: 'item-' + id, - call_id: id, - name, - ...(namespace ? { namespace } : {}), - arguments: JSON.stringify(arguments_), -}); -// Codex 0.155 起原生执行入口是统一 exec:`exec_command` + `write_stdin`(旧 `shell_command` -// 已不再注册)。命令在 yield_time_ms 内结束时不返回 session,保持与旧用例同样的同步语义。 -const native = (id, source) => - call(id, 'exec_command', { - cmd: nodeCommand(source), - yield_time_ms: 30_000, - }); -const register = (artifact) => - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '仅测试客户端执行许可,修改临时已有工程的标记文件,不生成素材或运行游戏。', - changeKind: 'project', - requirements: [{ kind: 'artifact', id: 'marker', path: artifact }], - }, - 'mcp__agc_tools', - ); -const final = { - type: 'message', - id: 'fixture-final', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Fixture actions finished; use the host delivery result.', - }, - ], -}; -// 工具回执正文既可能是字符串,也可能是 input_text 分片数组。 -const toolOutputText = (value) => - typeof value === 'string' - ? value - : Array.isArray(value) - ? value.map((part) => part?.text ?? '').join('') - : ''; -// 原生工具的输入 schema 只随第一份请求回执留档一次,用于版本升级后核对参数形状。 -const NATIVE_SCHEMA_TOOLS = new Set([ - 'exec_command', - 'write_stdin', - 'shell_command', - 'view_image', - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', -]); -const describeTool = (tool, withSchema = false) => ({ - type: tool.type, - name: tool.name ?? tool.function?.name, - ...(tool.namespace ? { namespace: tool.namespace } : {}), - ...(withSchema && tool.parameters ? { parameters: tool.parameters } : {}), - ...(Array.isArray(tool.tools) - ? { tools: tool.tools.map((entry) => describeTool(entry, false)) } - : {}), -}); - -async function walk(directory) { - const output = []; - for (const item of await fs - .readdir(directory, { withFileTypes: true }) - .catch(() => [])) { - const file = path.join(directory, item.name); - if (item.isDirectory()) output.push(...(await walk(file))); - else if (item.isFile()) output.push(file); - } - return output; -} - -async function readLedger(host) { - for (const file of await walk(path.join(host, 'direct-executions'))) { - if (!file.endsWith('.json')) continue; - const value = await fs - .readFile(file, 'utf8') - .then(JSON.parse) - .catch(() => null); - if (value?.schemaVersion === 'agc-direct-execution.v1') - return { file, value }; - } - return null; -} - -function ownFixtureTree(directory) { - if (process.platform !== 'win32') return; - assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); - // Elevated Windows shells otherwise create Administrators-owned objects. - // Only this newly created fixture subtree is adjusted to its launching user. - const result = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - "$ErrorActionPreference='Stop'; $fixtureRoot=$env:AGC_FIXTURE_OWNER_ROOT; " + - '$fixtureSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; ' + - '$fixtureItems=@(Get-Item -LiteralPath $fixtureRoot)+@(Get-ChildItem -LiteralPath $fixtureRoot -Recurse -Force); ' + - 'foreach($fixtureItem in $fixtureItems){$fixtureAcl=$fixtureItem.GetAccessControl(); $fixtureAcl.SetOwner($fixtureSid); $fixtureItem.SetAccessControl($fixtureAcl)}', - ], - { - env: { ...process.env, AGC_FIXTURE_OWNER_ROOT: directory }, - windowsHide: true, - encoding: 'utf8', - }, - ); - assert.equal( - result.status, - 0, - 'fixture ownership setup failed: ' + result.stderr, - ); -} - -async function installFixtureMcp( - host, - directory, - readOnlyHint, - delayMs = 800, - doneMarker = '', -) { - const source = path.join(host, 'extensions', 'sources', 'fixture'); - await fs.mkdir(source, { recursive: true }); - const program = path.join(source, 'server.cjs'); - const dispatch = path.join(directory, 'mcp-dispatch.jsonl'); - await fs.writeFile( - program, - [ - "const rl=require('node:readline').createInterface({input:process.stdin});", - "const fs=require('node:fs'); const target=process.argv[2];", - "rl.on('line',async(line)=>{let x;try{x=JSON.parse(line)}catch{return}if(x.id===undefined)return;", - 'let result;switch(x.method){', - "case 'initialize':result={protocolVersion:x.params.protocolVersion,capabilities:{tools:{},resources:{}},serverInfo:{name:'fixture',version:'1'}};break;", - "case 'tools/list':result={tools:[{name:'execute',description:'Fixture execution with explicit MCP annotations',inputSchema:{type:'object',properties:{step:{type:'integer'}},required:['step']},annotations:{readOnlyHint:" + - readOnlyHint + - ',idempotentHint:true}}]};break;', - "case 'tools/call':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,arguments:x.params.arguments,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r," + - delayMs + - "));if(process.argv[3])fs.writeFileSync(process.argv[3],'done');fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result={content:[{type:'text',text:'fixture dispatched'}]};break;", - "case 'resources/list':case 'resources/templates/list':case 'resources/read':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r,800));fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result=x.method==='resources/list'?{resources:[{uri:'fixture://state',name:'state',mimeType:'text/plain'}]}:x.method==='resources/templates/list'?{resourceTemplates:[{uriTemplate:'fixture://{name}',name:'fixture',mimeType:'text/plain'}]}:{contents:[{uri:x.params.uri,text:'fixture resource',mimeType:'text/plain'}]};break;", - "case 'ping':result={};break;", - "default:process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,error:{code:-32601,message:'unsupported fixture method'}})+'\\n');return;", - "}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,result})+'\\n');});", - ].join('\n'), - ); - const config = { - command: process.execPath, - args: [program, dispatch, doneMarker], - tool_timeout_sec: 10, - }; - const document = JSON.stringify({ mcpServers: { fixture: config } }); - await fs.writeFile(path.join(source, 'mcp.json'), document); - await fs.writeFile( - path.join(host, 'extensions', 'index.json'), - JSON.stringify({ - schemaVersion: 'direct-project-client-extensions.v1', - sources: [ - { - id: 'fixture-source', - originalName: 'fixture', - storagePath: 'sources/fixture', - fingerprint: hash(document), - }, - ], - items: [ - { - id: 'fixture-mcp', - sourceId: 'fixture-source', - extensionType: 'mcp', - name: 'fixture', - originalName: 'fixture', - sourceRelativePath: 'mcp.json', - enabled: true, - fingerprint: hash(document), - lastError: null, - mcpConfig: config, - }, - ], - }), - ); - return dispatch; -} - -async function runScenario(name) { - assert( - [ - 'completed', - 'passes', - 'mcp', - 'mcp-write', - 'native', - 'native-resources', - 'patch', - 'deadline', - 'native-session', - ].includes(name), - 'unknown fixture case', - ); - const directory = path.join(root, name); - const project = path.join(directory, 'project'); - const host = path.join(directory, 'host'); - const home = path.join(directory, 'home'); - await Promise.all( - [ - project, - host, - home, - path.join(home, 'appdata'), - path.join(home, 'local'), - ].map((dir) => fs.mkdir(dir, { recursive: true })), - ); - // An existing editor project avoids turning this execution-boundary fixture - // into a new-Web-game/bootstrap/visual-quality test. - await fs.writeFile( - path.join(project, 'project.godot'), - 'config_version=5\n[application]\nconfig/name="AGC execution fixture"\n', - ); - const isMcp = name === 'mcp' || name === 'mcp-write'; - const mcpDispatch = - isMcp || name === 'native-resources' || name === 'patch' - ? await installFixtureMcp( - host, - directory, - name === 'mcp', - name === 'patch' ? 4_000 : 800, - name === 'patch' ? path.join(project, 'mcp-done.txt') : '', - ) - : null; - const writes = (filename, text) => - "require('node:fs').writeFileSync(" + - JSON.stringify(filename) + - ',' + - JSON.stringify(text) + - ');'; - const nativeOverlapCall = (id) => - native( - id, - "const f=require('node:fs');const file=" + - JSON.stringify(id + '.jsonl') + - ";const mark=phase=>f.appendFileSync(file,JSON.stringify({phase,at:Date.now()})+'\\n');mark('start');setTimeout(()=>mark('end'),1000);", - ); - const plan = - name === 'completed' - ? [ - [register('marker.txt')], - [native('marker', writes('marker.txt', 'verified'))], - [ - native( - 'forbidden-after-completion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'deadline' - ? [ - [register('never.txt')], - [ - native( - 'slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run') + - '},8000);', - ), - ], - ] - : isMcp - ? [ - [register('never.txt')], - [ - call('mcp-a', 'execute', { step: 1 }, 'mcp__fixture'), - call('mcp-b', 'execute', { step: 1 }, 'mcp__fixture'), - ], - [native('fail', 'process.exit(1);')], - [call('forbidden-mcp', 'execute', { step: 2 }, 'mcp__fixture')], - ] - : name === 'native' - ? [ - [register('never.txt')], - [nativeOverlapCall('native-a'), nativeOverlapCall('native-b')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-native', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'native-resources' - ? [ - [register('never.txt')], - [ - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', - ].flatMap((tool) => - [1, 2].map((i) => - call(tool + '-' + i, tool, { - server: 'fixture', - ...(tool === 'read_mcp_resource' - ? { uri: 'fixture://state' } - : {}), - }), - ), - ), - [native('fail', 'process.exit(1);')], - ] - : name === 'native-session' - ? [ - [register('session.txt')], - [ - call('session', 'exec_command', { - cmd: nodeCommand( - "const f=require('node:fs');setTimeout(()=>f.writeFileSync('session.txt','session completed'),12000);", - ), - yield_time_ms: 10_000, - }), - ], - (body) => { - // 统一 exec 超过 yield_time_ms 会返回会话号;用 write_stdin 轮询到会话结束。 - const output = (body.input ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => toolOutputText(item.output)) - .join('\n'); - const match = /session ID (\d+)/.exec(output); - assert( - match, - 'exec_command did not return a unified exec session id: ' + - output.slice(0, 400), - ); - return [ - call('poll', 'write_stdin', { - session_id: Number(match[1]), - chars: '', - yield_time_ms: 20_000, - }), - ]; - }, - [final], - ] - : name === 'patch' - ? [ - [ - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '验收并发补丁、计划与慢工具的正常完成;两个必需产物都必须出现。', - changeKind: 'project', - requirements: [ - { - kind: 'artifact', - id: 'patch', - path: 'patch-proof.txt', - }, - { - kind: 'artifact', - id: 'mcp', - path: 'mcp-done.txt', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - call( - 'slow-mcp', - 'execute', - { step: 1 }, - 'mcp__fixture', - ), - call( - 'patch', - 'agc_apply_patch', - { - patch: - '*** Begin Patch\n*** Environment ID: local\n*** Add File: patch-proof.txt\n+parallel patch proof\n*** End Patch', - }, - 'mcp__agc_tools', - ), - call( - 'plan', - 'agc_update_plan', - { - explanation: '并行计划回执', - plan: [ - { - step: 'parallel-plan-proof', - status: 'completed', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - native( - 'forbidden-patch-expansion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : [ - [register('never.txt')], - [native('build', 'console.log(1+1);')], - [native('test', 'console.log(2+2);')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-after-budget', - writes('forbidden.txt', 'must not run'), - ), - ], - ]; - const requests = []; - const responses = []; - const httpArrivals = []; - const fixtureErrors = []; - const server = http.createServer(async (request, response) => { - httpArrivals.push({ - at: Date.now(), - method: request.method, - url: request.url, - expectedAuthorization: - request.headers.authorization === 'Bearer agc-loopback-fixture', - }); - try { - assert.equal(request.method, 'POST'); - assert(request.url.endsWith('/responses')); - assert.equal( - request.headers.authorization, - 'Bearer agc-loopback-fixture', - ); - const chunks = []; - let bytes = 0; - for await (const chunk of request) { - bytes += chunk.length; - assert(bytes <= 32 * 1024 * 1024, 'oversized fixture request'); - chunks.push(chunk); - } - const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const index = requests.length; - requests.push({ - index, - at: Date.now(), - model: requestBody.model, - parallelToolCalls: requestBody.parallel_tool_calls, - ...(index === 0 - ? { - toolCatalogue: (requestBody.tools ?? []).map((tool) => - describeTool(tool, NATIVE_SCHEMA_TOOLS.has(tool.name)), - ), - } - : {}), - // 只留档工具回执的有界原文,用于核对统一 exec 的会话/退出结果契约。 - inputs: (requestBody.input ?? []).map((item) => ({ - type: item.type, - name: item.name, - call_id: item.call_id, - raw: JSON.stringify(item).slice(0, 1500), - })), - }); - assert(index < 20, 'fixture model loop exceeded bound'); - if (index === plan.length - 1 && name !== 'deadline') { - // Allow host receipt settlement/sealing to win before an intentionally - // unwanted expansion. A closed transport is a valid pre-dispatch stop. - await sleep(300); - } - const step = plan[index] ?? [final]; - const output = typeof step === 'function' ? step(requestBody) : step; - - responses.push({ - index, - items: output.map((item) => JSON.stringify(item).slice(0, 300)), - }); - const body = { - id: 'response-' + index, - object: 'response', - model: 'gpt-5.1-codex', - status: 'completed', - output, - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - }; - const events = [ - { - type: 'response.created', - response: { ...body, status: 'in_progress', output: [] }, - }, - ]; - output.forEach((item, output_index) => { - events.push({ type: 'response.output_item.added', output_index, item }); - events.push({ type: 'response.output_item.done', output_index, item }); - }); - events.push({ type: 'response.completed', response: body }); - response.writeHead(200, { 'content-type': 'text/event-stream' }); - if (name === 'patch' && index === 1) { - // One response, streamed incrementally: prove the long operation really - // started before offering independent patch/plan calls to the scheduler. - response.write( - events - .slice(0, 3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - const startedDeadline = Date.now() + 5_000; - while (Date.now() < startedDeadline) { - const log = await fs.readFile(mcpDispatch, 'utf8').catch(() => ''); - if ( - log - .split('\n') - .some( - (line) => - line.includes('"method":"tools/call"') && - line.includes('"phase":"start"'), - ) - ) - break; - await sleep(20); - } - response.end( - events - .slice(3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - return; - } - response.end( - events - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - } catch (error) { - fixtureErrors.push(String(error)); - if (!response.destroyed) { - response.writeHead(400); - response.end(String(error)); - } - } - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - await fs.writeFile( - path.join(host, 'game-creator.config.json'), - JSON.stringify({ - schemaVersion: 'game-creator-config.v2', - agentMode: 'codex_app_server', - llm: { - customEnabled: true, - apiKey: 'agc-loopback-fixture', - baseUrl: 'http://127.0.0.1:' + address.port + '/v1', - model: 'gpt-5.1-codex', - visibleModels: ['gpt-5.1-codex'], - apiKind: 'openai_responses', - stream: true, - webSearchEnabled: false, - reasoningEffort: 'low', - maxRetries: 0, - requestTimeoutMs: 60_000, - }, - validation: { - maxRuns: 1, - maxExecutionSeconds: name === 'deadline' ? 2 : 30, - maxTurnSeconds: 60, - }, - }), - ); - ownFixtureTree(directory); - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => - !/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION|COOKIE/i.test(key), - ), - ); - Object.assign(env, { - GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', - HOME: home, - USERPROFILE: home, - APPDATA: path.join(home, 'appdata'), - LOCALAPPDATA: path.join(home, 'local'), - PATH: path.dirname(codex) + path.delimiter + (env.PATH ?? env.Path ?? ''), - }); - let stdout = '', - stderr = ''; - let planObservedAt = null; - let observingPlan = false; - let planObservationTask = Promise.resolve(); - const planWatch = - name === 'patch' - ? setInterval(() => { - if (planObservedAt !== null || observingPlan) return; - observingPlan = true; - planObservationTask = readLedger(host) - .then((entry) => { - if ( - JSON.stringify(entry?.value.plan ?? null).includes( - 'parallel-plan-proof', - ) - ) - planObservedAt = Date.now(); - }) - .catch(() => {}) - .finally(() => { - observingPlan = false; - }); - }, 20) - : null; - const child = spawn( - executable, - [ - '--config-dir', - host, - '--direct-codex-chat', - project, - '测试现有临时工程的宿主执行许可;只按fixture合同操作,不生成平台素材。', - ], - { - cwd: directory, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - child.stdout.on('data', (data) => { - stdout = (stdout + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - child.stderr.on('data', (data) => { - stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - const timeout = setTimeout(() => child.kill(), 90_000); - const exit = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => resolve({ code, signal })); - }).finally(() => clearTimeout(timeout)); - if (planWatch) clearInterval(planWatch); - await planObservationTask; - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - if (name === 'deadline') await sleep(9_000); - const ledger = await readLedger(host); - const dispatchEvents = mcpDispatch - ? (await fs.readFile(mcpDispatch, 'utf8').catch(() => '')) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse) - : []; - const dispatched = dispatchEvents.filter( - (entry) => entry.phase === 'start' && entry.method === 'tools/call', - ); - const intervals = dispatched.map((entry) => ({ - id: entry.id, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const overlapMs = - intervals.length === 2 && intervals.every((entry) => entry.end) - ? Math.min(...intervals.map((entry) => entry.end)) - - Math.max(...intervals.map((entry) => entry.start)) - : null; - const nativeIntervals = []; - if (name === 'native') - for (const id of ['native-a', 'native-b']) { - const entries = ( - await fs - .readFile(path.join(project, id + '.jsonl'), 'utf8') - .catch(() => '') - ) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse); - nativeIntervals.push({ - id, - start: entries.find((entry) => entry.phase === 'start')?.at, - end: entries.find((entry) => entry.phase === 'end')?.at, - }); - } - const nativeOverlapMs = - nativeIntervals.length === 2 && nativeIntervals.every((entry) => entry.end) - ? Math.min(...nativeIntervals.map((entry) => entry.end)) - - Math.max(...nativeIntervals.map((entry) => entry.start)) - : null; - const resourceIntervals = dispatchEvents - .filter( - (entry) => - entry.phase === 'start' && - entry.method.startsWith('resources/') && - entry.at >= (requests[1]?.at ?? Infinity), - ) - .map((entry) => ({ - id: entry.id, - method: entry.method, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const resourceOverlaps = Object.fromEntries( - ['resources/list', 'resources/templates/list', 'resources/read'].map( - (method) => { - const matching = resourceIntervals.filter( - (entry) => entry.method === method, - ); - return [ - method, - matching.length === 2 && matching.every((entry) => entry.end) - ? Math.min(...matching.map((entry) => entry.end)) - - Math.max(...matching.map((entry) => entry.start)) - : null, - ]; - }, - ), - ); - const report = { - scenario: name, - directory, - exit, - requests, - httpArrivals, - fixtureErrors, - ledger: ledger?.value, - responses, - dispatched, - dispatchEvents, - intervals, - overlapMs, - mcpReadOnlyHint: mcpDispatch ? name === 'mcp' : undefined, - nativeIntervals, - nativeOverlapMs, - resourceIntervals, - resourceOverlaps, - planObservedAt, - patchModifiedAt: await fs.stat(path.join(project, 'patch-proof.txt')).then( - (stat) => stat.mtimeMs, - () => null, - ), - stdout, - stderr, - markers: { - ready: await exists(path.join(project, 'marker.txt')), - forbidden: await exists(path.join(project, 'forbidden.txt')), - started: await exists(path.join(project, 'started.txt')), - late: await exists(path.join(project, 'late.txt')), - }, - }; - await fs.writeFile( - path.join(directory, 'result.json'), - JSON.stringify(report, null, 2), - ); - assert( - requests.length > 0, - name + ': AGC did not reach the loopback fixture', - ); - assert( - requests.every((request) => request.parallelToolCalls === true), - name + - ': Direct Responses requests must allow multiple tool calls without changing the selected model', - ); - assert(ledger, name + ': missing host-authoritative ledger'); - assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json'); - assert.equal( - report.markers.forbidden, - false, - name + ': effect ran after host terminal', - ); - assert.equal( - ledger.value.usedPasses, - 1, - name + ': ordinary commands must share one pass', - ); - assert.equal( - ledger.value.phase, - ['completed', 'patch', 'native-session'].includes(name) - ? 'completed' - : 'exhausted', - ); - assert.equal( - ledger.value.executorStopped, - true, - name + ': missing full executor exit proof', - ); - if (name === 'completed') assert.equal(report.markers.ready, true); - if (isMcp) { - assert.equal( - dispatched.length, - 2, - 'untrusted readonly hints cannot authorize a post-terminal MCP call', - ); - assert(dispatched.every((entry) => entry.arguments.step === 1)); - assert( - overlapMs > 0, - name + - ': real Codex MCP calls ran serially; inspect start/end evidence before changing dispatch policy', - ); - } - if (name === 'deadline') { - assert.equal( - report.markers.started, - true, - 'must prove the real child started before the cutoff', - ); - assert.equal( - report.markers.late, - false, - 'turn interruption alone did not stop the child', - ); - } - if (name === 'native') - assert( - nativeOverlapMs > 0, - 'independent native write commands must overlap', - ); - if (name === 'native-resources') - for (const [method, overlap] of Object.entries(resourceOverlaps)) - assert( - overlap > 0, - method + ': real native resource reads did not overlap', - ); - if (name === 'patch') { - const catalogue = requests[0].toolCatalogue; - assert( - !catalogue.some((tool) => - ['apply_patch', 'update_plan', 'request_user_input'].includes( - tool.name, - ), - ), - 'serial native registrations must be absent', - ); - const owned = - catalogue.find((tool) => tool.name === 'mcp__agc_tools')?.tools ?? []; - for (const tool of ['agc_apply_patch', 'agc_update_plan']) - assert(owned.some((entry) => entry.name === tool)); - assert.equal( - intervals.length, - 1, - 'exactly one slow third-party call must execute', - ); - assert.equal( - await fs.readFile(path.join(project, 'patch-proof.txt'), 'utf8'), - 'parallel patch proof\n', - ); - assert.equal( - await fs.readFile(path.join(project, 'mcp-done.txt'), 'utf8'), - 'done', - ); - for (const [action, at] of Object.entries({ - patch: report.patchModifiedAt, - plan: planObservedAt, - })) - assert( - at > intervals[0].start && at < intervals[0].end, - action + ' did not complete while the slow MCP call was running', - ); - } - if (name === 'native-session') { - const execOutput = requests - .flatMap((entry) => entry.inputs ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => { - try { - return toolOutputText(JSON.parse(item.raw).output); - } catch { - return ''; - } - }) - .join('\n'); - assert( - /Process running with session ID \d+/.test(execOutput), - 'exec_command must return a unified exec session id', - ); - assert( - await exists(path.join(project, 'session.txt')), - 'write_stdin polling must let the unified exec session finish', - ); - assert.equal( - ledger?.value?.phase, - 'completed', - 'host acceptance must complete after the session artifact appears', - ); - } - console.log( - JSON.stringify({ - scenario: name, - passed: true, - result: path.join(directory, 'result.json'), - }), - ); -} - -const failures = []; -for (const name of cases) { - try { - await runScenario(name); - } catch (error) { - failures.push({ scenario: name, error: String(error) }); - console.error(JSON.stringify(failures.at(-1))); - } -} -assert.equal( - failures.length, - 0, - 'production fixture failures: ' + JSON.stringify(failures), -); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index fdf0ded68..3fa5066b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2148,24 +2148,10 @@ pub(crate) fn record_direct_codex_failure( public_text } -/// 命令边界的错误文本:可留痕的调用级入队失败在这里补一份运行错误诊断(文案里不带诊断引用), -/// 其余只输出 [`DirectTurnError`] 的 `Display`。 -/// -/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 -/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; -/// 回合级失败已在上游写过诊断,这里直接放行。 -pub(crate) fn direct_turn_error_boundary_text( - root: &Path, - client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> String { - direct_turn_enqueue_failure(root, client_turn_id, failure).message -} - /// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// -/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 +/// 命令边界只有这一份判据,禁止在各处再写一套。 pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, @@ -4432,39 +4418,6 @@ pub(crate) fn check_direct_turn_preconditions( .map_err(|detail| DirectTurnError::InputRejected { detail })?; Ok(()) } -pub(crate) async fn run_direct_game_creator_turn_at( - root: &Path, - prompt: &str, -) -> Result { - // The CLI entry point does not receive the GUI's clientTurnId. Still arm - // one invocation identity so an otherwise optional AGC generation tool - // cannot fail merely because the request came through the CLI. This is - // local execution identity only; it does not create a Runtime task or DAG. - let invocation_id = format!("direct-cli-{}", unix_millis()); - let _invocation = DirectTaonierActiveInvocationGuard::enter(root, &invocation_id)?; - run_direct_game_creator_turn_at_with_creation_type(root, prompt, None).await -} - -pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( - root: &Path, - prompt: &str, - creation_type: Option<&str>, -) -> Result { - // CLI 入口没有"入队 / 放行"这两步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在入队半里过同一份判据。 - check_direct_turn_preconditions(root, prompt, creation_type)?; - run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - prompt, - creation_type, - None, - None, - None, - None, - ) - .await -} - pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, @@ -7739,13 +7692,14 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, Some("direct-codex:turn-1:user"), DirectTurnError::EnvironmentNotReady { detail: "Codex app-server 启动失败:找不到可执行文件".into(), }, - ); + ) + .message; assert!(text.contains("direct-codex-failure:v2"), "{text}"); assert!(!text.contains("详情:"), "{text}"); @@ -7765,7 +7719,7 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; assert_eq!(text, "聊天内容不能为空"); assert!(!root.join(".agent/runtime/errors").exists()); @@ -7782,11 +7736,12 @@ mod tests { DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); let recorded = record_direct_codex_failure(&root, &failure, None); // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, None, DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ); + ) + .message; assert_eq!(text, recorded); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index de0bdc053..2ea1be4d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -39,9 +39,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 /// 错误诊断,返回串不带诊断引用。 /// -/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;两个入口共用 [`direct_turn_error_boundary_text`] / -/// [`direct_turn_enqueue_failure`],不要再各写一套判据。 +/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 +/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// /// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 05c532102..1850fe457 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -14,10 +14,6 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, - DirectCodexChat { - project_path: PathBuf, - prompt: String, - }, AgentTask { project_path: PathBuf, agent_id: String, @@ -178,7 +174,6 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } - | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -699,19 +694,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } - if args.first().map(String::as_str) == Some("--direct-codex-chat") { - if args.len() < 3 { - return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); - } - let prompt = args[2..].join(" "); - if prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); - } - return Ok(Some(CliCommand::DirectCodexChat { - project_path: PathBuf::from(&args[1]), - prompt: prompt.trim().to_string(), - })); - } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -896,44 +878,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } - CliCommand::DirectCodexChat { - project_path, - prompt, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; - if !project_path.join(".agent/manifest.json").is_file() { - let project_name = project_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Codex 直连项目"); - init_local_game_project_at( - &project_path, - &format!("direct-codex-{}", unix_millis()), - project_name, - )?; - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; - let reply_result = runtime.block_on(async { - run_direct_game_creator_turn_at(&project_path, &prompt) - .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 - // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, - // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 - .map_err(|failure| { - direct_turn_error_boundary_text(&project_path, None, failure) - }) - }); - let shutdown_result = shutdown_game_creator_codex_app_servers(); - let reply = reply_result?; - shutdown_result?; - println!("direct-codex.chat.completed"); - println!("projectPath={}", project_path.display()); - println!("replyText={reply}"); - Ok(()) - } CliCommand::AgentTask { project_path, agent_id, diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 5dc829f89..7069264bc 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -110,7 +110,9 @@ 界面不会卡在忙碌态。 - 必须同步的注释:`chat/controller/useDirectProjectChatController.ts`(catch 的职责)、 `chat/conversation/directTurnPresentation.ts`("`invoke` 直到整轮结束才返回"这句会变成错的)。 -- CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。 +- ~~CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。~~ **口径反转**:`--direct-codex-chat` + 整个退役(见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](./【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + DirectProject 只剩「入队」一个命令入口。 ## 备选方案与取舍 diff --git a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md index 581077bf8..576fc818c 100644 --- a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md +++ b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md @@ -36,7 +36,12 @@ - 命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(项目权限策略词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计粒度,数组本体由 Rust 策略路径消费)。 - `needsInitializedChatProject` 与项目权限策略链路。 - Rust 侧路径/路由的 `/` 前缀校验。 -- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check`、`--direct-codex-chat` 等运维与开发 CLI 控制命令。 +- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check` 等运维与开发 CLI 控制命令。 + +已退役(口径变更,见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)): +- `--direct-codex-chat`(本文件此前列为「范围外(保留)」):DirectProject 命令入队化之后不再保留第二条直接起回合的入口, + 它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`,两者一起退役;同批删掉只服务它的 + `run_direct_game_creator_turn_at` / `run_direct_game_creator_turn_at_with_creation_type`。 - 保留的真实 E2E 套件:`supervisor-swarm`、`supervisor-swarm-transient-retry`、`supervisor-swarm-final-reply-transient-retry`、`supervisor-swarm-tool-plan-handoff-runner-kill`、`goal-runtime`、`response-stream`、`web-search`、`context-compaction`、`scoped-agents`、`project-skill`、`parallel-read`、`steer-runner-kill`、`process-session`。 ## 检查点 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8304d518..b8089f910 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9491,7 +9491,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。 - 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。 - 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。** -- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。 +- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。**(口径修正:本条已作废——2026-09-24 的 `--direct-codex-chat` 退役把 CLI 入口整个删掉,DirectProject 只剩「入队」一个命令入口;见本文件同日「命令入队化」那条与 `【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`。)** - 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 - 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 @@ -9598,11 +9598,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 再落盘用户条目、下发用户条目、起整轮。**放行不重跑检查、不存在放行失败**,放行之后的一切失败都是回合失败, 走既有 `turn.completed.failure`,不新增通道。kick 点 = 回合任务收尾(含 drop 守卫盖 panic)+ 中止路径 + 入队之后, 幂等且在临界区里认领队首;入队不取 `DirectTaonierActiveInvocationGuard`(它必须整轮持有,是这一轮的调用身份)。 -- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿"); - 埋点句柄改由宿主在放行时开。 +- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿")。 + **(落地修正,2026-09-24)**:埋点句柄仍由前端在入队那一刻生成并随命令交给宿主(放行不重算,与其它入队检查产物一起 + 存进队列条目),前端把句柄按 `clientTurnId` 存成一张表、回合终态按本轮开口条目的 canonical 身份认领结算; + "不丢草稿"由「提交等命令的入队结果、只有用户自己能改的入队失败才返回 false」实现(不再是"宿主在放行时开句柄")。 - 决策(顺带退役):`--direct-codex-chat` 整个退役——它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`(PR #439 引入、不在 CI、无 npm/harness 注册、无测试钉它), - 夹具一并退役;`TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"分支随之删除。 + 夹具一并退役。**(落地修正,2026-09-24)**:`TurnAlreadyRunning` 的最后一个生产点在调用身份守卫里 + (占用登记那条早已随入队化消失),所以它连同前端"同一轮消息仍在处理中"分支一起放到**守卫清理那一步**再删。 `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 2416050e7..bfae9ff8c 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -15,7 +15,7 @@ | 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | -| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | | 第 5 步 守卫清理 | 待做 | | ## 第 0 步:词表切换(与代码同批,不单独提交) diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md index 3fa638824..0e34cdbc3 100644 --- a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -165,7 +165,8 @@ Supervisor / 做方案首轮忽略 `attachments`,行为不变。 - `chat_with_game_creator_direct_codex` 增加 `attachments: Option>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` - 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现 -不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。 +不要把 attachments 顺着 inner turn / emitter 往下传。(CLI 入口 `run_direct_game_creator_turn_at` 已随命令入队化退役,见 +[`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。) ### 5.2 前端 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md index d01893b3a..68be727f2 100644 --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -38,7 +38,7 @@ Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动 - 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。 - 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。 - 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。 -- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。 +- DirectHome、ToolHost 本期不写这份账本。(`--direct-codex-chat` 已随命令入队化退役,不再是入口。) - 本期不改 UI,不在聊天面板展示审计。 - 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。 @@ -292,7 +292,9 @@ chat_with_game_creator_direct_codex → 写 turn_end + agent.db 摘要 ``` -- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。 +- CLI 入口已退役:`run_direct_game_creator_turn_at` 与 `--direct-codex-chat` 一起删除(见 + [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + 所以「无 `clientTurnId` 的 Direct 回合」这条路径不存在了。 - Home command 不接 audit。 - 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。 - `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。 From 41c03d7199851e7b757fe0ff0c5e7e982ae3bf59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:30:16 +0800 Subject: [PATCH 29/60] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=A0=B8?= =?UTF-8?q?=E5=AE=9E=E5=88=A0=E8=B0=83=E7=94=A8=E8=BA=AB=E4=BB=BD=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E7=9A=84=E5=89=8D=E7=BD=AE=E5=88=A4=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=AC=AC=205=20=E6=AD=A5=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR §9 记录核实结论:取消兜底本来就同时释放占用、补写 turn.completed 并踢队列,删守卫不需要新增无条件终态;连接死亡的失败事实由 app-server 连接层落地 - 实施计划第 5 步改写:60 秒启动窗口闸门改挂 Thread Manager 的占用登记年龄,读者改读 direct_active_turn_id_at,TurnAlreadyRunning 一并删 - decision-log 追加 2026-09-30 条目,记录核实过程、决策与影响范围 --- ...命令入队化与待发消息队列归宿主-2026-09-24.md | 12 ++++++--- .../shared-memory/decision-log.md | 21 +++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 27 ++++++++++++------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 67afbd699..90d8b2735 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -85,9 +85,15 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - CLI 一退,`DirectTurnError::TurnAlreadyRunning` 的两个生产点(调用身份守卫、占用登记)都没有调用方, 它连同前端"同一轮消息仍在处理中"文案、专属分支与测试一起删。 - `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 - (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:第二步让那五个读者 - 改读 Thread Manager 的活动回合身份,守卫连同它的测试与 60 秒卡死兜底一起删。删之前必须确认取消路径的无条件终态 - 仍然解得开"回合卡死"(那次兜底来自真实事故 `d833ca9d3`)。 + (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:五个读者 + (`direct_execution` / `direct_tool_bridge` / `direct_validation` / `direct_project_context` / 付费美术重生成) + 改读 Thread Manager 的活动回合身份,守卫连同它的测试一起删,不再有第二份进程内记录。 +- 删守卫的前置判据**已核实(2026-09-30)**,不需要新增"取消路径补无条件终态":`d833ca9d3` 的兜底路径本来就 + 同时做三件事——释放占用、往事件流补一条 `turn.completed{aborted}`(否则前端会永远停在运行中)、调 + `complete_direct_thread_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; + 唯一独有的东西是 60 秒启动窗口闸门,改挂在 Thread Manager 的占用登记年龄上 + (`direct_stale_turn_for_release` 的 `NeverReachedExecutor` 分支)。连接死亡那条失败事实由 app-server + 连接层自己落地(`ab970b9fd`),与守卫无关。 ## 备选方案与取舍 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b8089f910..66dc906c3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9614,3 +9614,24 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 失去"真实二进制驱动执行层生产验证"这条手工路径。 - 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、 `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +## 2026-09-30 守卫清理前置判据核实:取消兜底不需要新增无条件终态,60 秒闸门改挂占用登记年龄 + +- 背景:按 09-24 ADR §9 的计划,删 `DirectTaonierActiveInvocationGuard` 前必须确认"回合任务 park / 泄漏时, + 取消仍能清空占用并让队列继续放行"。查 git 历史(`d833ca9d3` / `ab970b9fd`)与当前实现后确认: + `cancel_direct_codex_turn_at` 的兜底路径本来就同时做三件事——释放占用、往事件流补一条 + `turn.completed{aborted}`(否则前端会永远停在运行中)、调 `complete_direct_thread_turn` 解除占用并踢队列。 + 所以删守卫**不需要**新增"取消路径补无条件终态",那是当时就有的东西,不是守卫带来的。 +- 决策:守卫只在两件事上是独有的——(1) 它是"这一轮是谁"的第二份进程内记录(与 Thread Manager 的 + `active_turn.turn_id` 同源),CLI 退役后已无第二个入口,按"同一件事只许有一处真相"删; + (2) 它的 60 秒启动窗口闸门(防"刚放行、还在本地准备的回合被终止误伤")保留,改挂在 Thread Manager + 的占用登记年龄上:`direct_stale_turn_for_release(root, expected, reason)` 只在 + `NeverReachedExecutor` 时校验年龄(`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`),返回身份;释放本身仍由 + 既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成(天然幂等)。 +- 决策:连接死亡的失败事实由 app-server 连接层自己落地(`ab970b9fd`),与守卫无关,删除守卫不动那条链路。 +- 影响范围:`agent/direct_runtime/mod.rs`(删守卫表 / 只读探测,改为 `direct_active_turn_id_at`)、 + `agent/direct_thread_manager.rs`(`DirectTurnIdentity` + `read_direct_turn_identity`)、 + `agent/codex_app_server/mod.rs`(兜底目标改 `DirectStaleTurnReleaseReason`)、五个身份读者与相关测试、 + 删 `DirectTurnError::TurnAlreadyRunning` 与前端对应分支 / 生成绑定。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`;`npx vitest run tests/appSurface.test.ts`; + `npm --workspace apps/ai-game-creator-shell run typecheck`;`npm run check:encoding`;`git diff --check`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index bfae9ff8c..8a7dc67f6 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,7 +2,7 @@ 更新时间:`2026-09-24` -状态:**实施中**(第 0–3 步已落地;第 4、5 步待做) +状态:**实施中**(第 0–4 步已落地;第 5 步进行中) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -16,7 +16,7 @@ | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | -| 第 5 步 守卫清理 | 待做 | | +| 第 5 步 守卫清理 | 进行中 | 前置判据已核实;改读 Thread Manager 活动回合身份、删调用身份守卫与其测试,并删 `TurnAlreadyRunning` | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -155,14 +155,21 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem ## 第 5 步:守卫清理(CLI 退役之后) -- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成、 - `agent/direct_validation.rs`、`agent/direct_project_context.rs`)改读 Thread Manager 的活动回合身份 - (新增一个按项目路径取 `active_turn.turn_id` 的只读入口)。 -- 删 `DirectTaonierActiveInvocationGuard` 与 `release_stale_direct_taonier_active_invocation` 及其测试 - (`direct_runtime/mod.rs` 的守卫用例、`direct_project_context.rs`、`direct_tool_bridge.rs`、`direct_tools_mcp.rs`、 - `user_input.rs`、`codex_app_server/mod.rs` 的集成用例),取消路径改为直接走无条件的 `complete_direct_thread_turn("aborted")`。 -- 前置判据:补一条测试证明"回合任务被 park / 泄漏时,取消仍能清空占用并让队列继续放行"——那次 60 秒兜底来自真实事故 - (`d833ca9d3`),删它必须有等价保证。 +> 前置判据**已核实(2026-09-30)**:`d833ca9d3` 的取消兜底本来就同时做三件事——释放占用、往事件流补一条 +> `turn.completed{aborted}`(否则前端永远停在运行中)、解除 `complete_direct_thread_turn` 占用;所以删守卫 +> **不需要**新增任何"取消路径补无条件终态"。连接死亡那条失败事实由 app-server 连接层自己落地(`ab970b9fd`), +> 也与守卫无关。守卫唯一独有的东西是 60 秒启动窗口闸门,改挂在 Thread Manager 的占用登记年龄上。 + +- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_validation.rs`、 + `agent/direct_project_context.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成)改读 + `direct_active_turn_id_at`(Thread Manager 的活动回合,只读、不改占用)。 +- 删 `DirectTaonierActiveInvocationGuard`、`DirectActiveTurnView`、`read_direct_taonier_active_invocation_at` 与 + `release_stale_direct_taonier_active_invocation` 及它们的测试;`codex_app_server` 的兜底目标改 + `DirectStaleTurnReleaseReason`,释放本身仍由既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成。 +- 删 `DirectTurnError::TurnAlreadyRunning`(最后一个生产点在守卫里)与前端"同一轮消息仍在处理中"文案、专属分支、 + 生成绑定与 `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- 前置判据测试:`cancel_direct_codex_turn_at` 在"回合任务泄漏、app-server 侧没有可中断句柄"时仍能解除占用, + 并把队首放行出去(`codex_app_server` 的兜底用例 + `direct_stale_turn_for_release` 的单测)。 ## 验收与证据 From ecac9d8e15e5dd94766a8e569048baeb52bcbe76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:39:32 +0800 Subject: [PATCH 30/60] =?UTF-8?q?DirectProject=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=BD=92=20Thread=20Manager=EF=BC=9A?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E7=AC=AC=E4=BA=8C=E4=BB=BD=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=86=85=E5=AE=88=E5=8D=AB=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Thread Manager 的只读身份入口:DirectTurnIdentity + read_direct_turn_identity,占用仍只由终态出口解除 - 五个身份读者(执行会话、工具桥、校验预约、上下文预取、付费美术重生成)改读 direct_active_turn_id_at - 删 DirectTaonierActiveInvocationGuard / DIRECT_TAONIER_ACTIVE_INVOCATIONS / DirectActiveTurnView 与只读探测 - 删 release_stale_direct_taonier_active_invocation,改为 direct_stale_turn_for_release 只做前置校验(身份一致 + 启动窗口),释放仍由 complete_direct_thread_turn + kick_direct_queue_dispatch 完成 - DirectStaleTurnReleaseReason 取代 DirectTaonierStaleGuardReason;终止兜底的注释与文案改成占用口径 - 放行不再另取调用身份:认领时已在 Thread Manager 登记,删掉认领后的守卫进入与失败分支 - 新增前置判据用例 stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn,并补背景拨时测试钩子 - 集成用例改用 DirectTurnReservation::accept_for_test 建占用,不再进守卫 --- .../src/agent/codex_app_server/mod.rs | 45 +-- .../src-tauri/src/agent/direct_execution.rs | 2 +- .../src/agent/direct_project_context.rs | 9 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 381 +++++------------- .../src/agent/direct_thread_manager.rs | 54 +++ .../src-tauri/src/agent/direct_tool_bridge.rs | 9 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 10 +- .../src/agent/direct_turn_dispatch.rs | 64 ++- .../src-tauri/src/agent/direct_validation.rs | 4 +- 9 files changed, 229 insertions(+), 349 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 4538e1c57..4af07737d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -4428,7 +4428,7 @@ fn register_active_direct_codex_turn( /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; -/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +/// 这一轮已经没有人替它收尾,占用已被兜底解除:界面必须自己复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 @@ -4444,12 +4444,12 @@ pub(crate) struct DirectTurnCancelView { pub(crate) client_turn_id: String, } -/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +/// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 enum DirectCodexTurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 - Stale(DirectTaonierStaleGuardReason), + Stale(DirectStaleTurnReleaseReason), } /// 终止当前项目正在运行的 Direct 回合。 @@ -4459,13 +4459,13 @@ enum DirectCodexTurnCancelTarget { /// 不动任何既有事件或命令语义。 /// /// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 -/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 -/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 -/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 -/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 -/// 保持原拒绝语义,什么都不释放。 +/// 只发中断会让 Thread Manager 上这一轮的占用永远解不开,这个项目此后每条消息都会被 +/// "已有另一条回合正在跑"挡在放行之外——这正是"重进会话被堵死"的死锁形态。这时显式写一条 +/// `turn.completed{aborted}` 解除占用并把可读原因返回给界面。释放条件见 +/// [`direct_stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, +/// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 +/// 兜底终态带 `userItemId`:身份取 [`direct_stale_turn_for_release`] 返回的 /// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 /// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { @@ -4502,10 +4502,10 @@ pub(crate) fn cancel_direct_codex_turn_at( DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) } Ok(_) => { - DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + DirectCodexTurnCancelTarget::Stale(DirectStaleTurnReleaseReason::ExecutorExited) } Err(_) => DirectCodexTurnCancelTarget::Stale( - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ), } }; @@ -4523,17 +4523,17 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - let released = - release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + // 先做前置校验:确实登记着一轮、身份对得上、过了启动窗口才允许判成残留。 + let released = direct_stale_turn_for_release(root, client_turn_id, reason)?; // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的放行。 + // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。走 Thread Manager 的 + // 深层出口而不是裸 append:这也是**解除占用的唯一出口**,解除不了这个 thread 会一直 + // 被认为是"还有没收口的回合",挡住后面的放行。 complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); - // 这一轮不会再有人替它收尾(守卫刚被兜底释放),占用也在这里解除了:踢一脚让队列继续。 + // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_direct_queue_dispatch(root); Ok(DirectTurnCancelView { @@ -8048,9 +8048,6 @@ done }); let thread_id = direct_thread_id_for_project(&project); let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); let _reservation = crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) @@ -8207,12 +8204,8 @@ done bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 - // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行(认领时登记调用身份), + // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 73a283b85..400202c54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -432,7 +432,7 @@ pub(super) async fn begin( let root = root.to_path_buf(); let prompt_hash = hash(prompt.as_bytes()); let session = tokio::task::spawn_blocking(move || { - let turn = super::direct_taonier_active_invocation_id_at(&root)?; + let turn = super::direct_active_turn_id_at(&root)?; let host = crate::game_creator_runtime_config_dir() .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; open_with_analytics_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 971d41931..772166b30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -349,11 +349,7 @@ pub(super) async fn prefetch_turn_input( let scan_root = root.to_path_buf(); let expected_turn = client_turn_id.to_string(); let files = tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&scan_root) - .ok() - .as_deref() - != Some(expected_turn.as_str()) - { + if direct_active_turn_id_at(&scan_root).ok().as_deref() != Some(expected_turn.as_str()) { return Vec::new(); } let candidates = [ @@ -590,8 +586,7 @@ mod tests { #[tokio::test] async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() { let (_temp, root) = project(); - // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 - let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); + // 预取闸门与上下文身份读的是同一处:Thread Manager 上这一轮的活动回合登记。 let _turn = DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "prefetch-turn", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 3fa5066b0..83040048a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1,11 +1,9 @@ use super::*; use base64::Engine as _; use std::collections::BTreeMap; -use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Mutex; mod user_input; pub(crate) use user_input::enqueue_direct_codex_turn; @@ -453,100 +451,16 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String format!("{:x}", hasher.finalize()) } -#[derive(Debug)] -struct DirectTaonierActiveInvocation { - invocation_id: String, - started_at: u64, -} - -static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< - Mutex>, -> = OnceLock::new(); - -/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 -/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 -/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 -/// 不可能再进入执行器,守卫是残留。 -const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; - -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - -#[derive(Debug)] -pub(crate) struct DirectTaonierActiveInvocationGuard { - root: PathBuf, - invocation_id: String, -} - -impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { - let root = root - .canonicalize() - .map_err(|error| DirectTurnError::ProjectRootUnanchored { - cause: error.to_string(), - })?; - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get_or_init(|| Mutex::new(HashMap::new())); - let mut active = active - .lock() - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "Direct 调用身份锁已损坏".to_string(), - })?; - match active.get(&root) { - Some(existing) => { - return Err(DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing.invocation_id.clone(), - incoming_invocation_id: invocation_id.to_string(), - }); - } - None => { - let started_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - active.insert( - root.clone(), - DirectTaonierActiveInvocation { - invocation_id: invocation_id.to_string(), - started_at, - }, - ); - } - } - Ok(Self { - root, - invocation_id: invocation_id.to_string(), - }) - } -} - -impl Drop for DirectTaonierActiveInvocationGuard { - fn drop(&mut self) { - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let remove = active - .get(&self.root) - .is_some_and(|existing| existing.invocation_id == self.invocation_id); - if remove { - active.remove(&self.root); - } - } -} - -pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| active.invocation_id.clone()) +/// 这个项目上正在跑的那一轮身份(`clientTurnId`),取自 **Thread Manager 的活动回合**。 +/// +/// 它是"这一轮是谁 / 在不在跑"的唯一登记:首页「运行中的项目」快照、放行闸门(有未收口回合就不认领 +/// 队首)读的都是这一处,不再另立第二份守卫表。 +/// +/// 拿不到身份时返回带前缀的错误:调用方(付费美术执行会话、工具桥、校验预约、上下文预取)都靠 +/// "拿不到身份"来拒绝"在没有回合的情况下创建 / 清理生成账本"。 +pub(crate) fn direct_active_turn_id_at(root: &Path) -> Result { + read_direct_turn_identity(&direct_thread_id_for_project(root)) + .map(|identity| identity.client_turn_id) .ok_or_else(|| { format!( "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 当前付费美术调用缺少稳定 clientTurnId,已拒绝创建或清理生成账本" @@ -554,49 +468,26 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| DirectActiveTurnView { - client_turn_id: active.invocation_id.clone(), - started_at: active.started_at, - })) +/// 上限用来挡"刚放行、还在本地准备(读配置 / manifest / 拼系统提示 / 开审计)的回合被终止误伤": +/// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 +const DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS: u64 = 60_000; + +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 } -/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +/// "终止"拿不到可中断句柄时的分类,决定这一轮能不能被兜底释放。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DirectTaonierStaleGuardReason { +pub(crate) enum DirectStaleTurnReleaseReason { /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 ExecutorExited, /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 NeverReachedExecutor, } -impl DirectTaonierStaleGuardReason { +impl DirectStaleTurnReleaseReason { pub(crate) fn message(self) -> &'static str { match self { Self::ExecutorExited => "陶泥儿执行进程已退出", @@ -605,53 +496,42 @@ impl DirectTaonierStaleGuardReason { } } -/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// 兜底释放前的前置校验:这一轮是不是**可以**被判成残留,返回它的 `clientTurnId`。 /// -/// 释放条件(四条必须同时成立,这段注释就是契约): -/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 条件(三条必须同时成立,这段注释就是契约): +/// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); /// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; -/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; -/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 -/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" -/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 +/// [`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; -/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 -pub(crate) fn release_stale_direct_taonier_active_invocation( +/// **释放本身不在这里**:调用方拿到身份后往 Thread Manager 写一条 `turn.completed{aborted}`, +/// 那才是解除占用的唯一出口(也因此天然幂等——占用已经没了就返回"没有正在运行的回合")。 +pub(crate) fn direct_stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, - reason: DirectTaonierStaleGuardReason, + reason: DirectStaleTurnReleaseReason, ) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; - let Some(existing) = active.get(&root) else { - return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); - }; + let thread_id = direct_thread_id_for_project(root); + let active = read_direct_turn_identity(&thread_id) + .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; if let Some(expected) = expected_client_turn_id .map(str::trim) .filter(|value| !value.is_empty()) { - if existing.invocation_id != expected { + if active.client_turn_id != expected { return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); } } - if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); - if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { + if reason == DirectStaleTurnReleaseReason::NeverReachedExecutor { + let age_ms = direct_taonier_active_now_millis().saturating_sub(active.started_at); + if age_ms < DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS { return Err(format!( "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", age_ms / 1000 )); } } - let released = existing.invocation_id.clone(); - active.remove(&root); - Ok(released) + Ok(active.client_turn_id) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -3262,7 +3142,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( )); } let mut regeneration_workflow = if mode.regenerates_existing() { - let invocation_id = direct_taonier_active_invocation_id_at(root)?; + let invocation_id = direct_active_turn_id_at(root)?; Some(prepare_direct_taonier_regeneration_workflow_at( root, prompt, @@ -5639,170 +5519,93 @@ mod tests { assert!(normalize_direct_client_turn_id(None).is_err()); } + /// "这一轮是谁"只有 Thread Manager 一处真相;读取是只读的,不改占用。 #[test] - fn active_client_turn_id_is_exclusive_until_the_outer_turn_finishes() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("first client turn"); - let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect_err("same stable turn is already running"); - assert!( - matches!( - &duplicate, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "client-turn-0001" - && incoming_invocation_id == "client-turn-0001" - ), - "{duplicate:?}" - ); - // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 - assert_eq!( - duplicate.to_string(), - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" - ); - let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") - .expect_err("different turn cannot take over the project"); - assert!( - different - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行"), - "{different}" - ); - drop(first); - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("lost-response replay after the original turn finishes"); - } + fn reading_the_active_turn_identity_does_not_take_over_the_occupancy() { + let root = tempfile::tempdir().expect("active turn root"); + let thread_id = direct_thread_id_for_project(root.path()); + assert_eq!(read_direct_turn_identity(&thread_id), None); - #[test] - fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { - let root = tempfile::tempdir().expect("active invocation root"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") - .expect("first client turn"); - let running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("running turn is visible to the read-only probe"); + let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-read-1"); + let running = read_direct_turn_identity(&thread_id).expect("running turn is readable"); assert_eq!(running.client_turn_id, "client-turn-read-1"); assert!(running.started_at > 0, "{running:?}"); - // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 assert_eq!( - serde_json::to_value(&running).expect("serialize view"), - serde_json::json!({ - "clientTurnId": "client-turn-read-1", - "startedAt": running.started_at, - }) + direct_active_turn_id_at(root.path()).expect("identity for the running turn"), + "client-turn-read-1" ); - // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 - let duplicate = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") - .expect_err("read-only probe must not take over the project"); - assert!(duplicate - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行")); + // 只读探测不释放占用:认领仍然被这一轮挡着。 + assert!(claim_direct_pending_turn(&thread_id).is_none()); - drop(first); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); + drop(reservation); + assert_eq!(read_direct_turn_identity(&thread_id), None); + assert!(direct_active_turn_id_at(root.path()).is_err()); } + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口。 #[test] - fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") - .expect("first client turn"); + fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("stale turn root"); + let thread_id = direct_thread_id_for_project(root.path()); + let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 - let mismatch = release_stale_direct_taonier_active_invocation( + let mismatch = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-2"), - DirectTaonierStaleGuardReason::ExecutorExited, + DirectStaleTurnReleaseReason::ExecutorExited, ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert!( - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() + assert_eq!( + read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) ); - // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 - let young = release_stale_direct_taonier_active_invocation( + // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ) - .expect_err("a freshly registered turn is still starting"); + .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 - backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); - let released = release_stale_direct_taonier_active_invocation( + // ③ 老过窗口:判成残留,调用方拿到身份(解除占用由调用方写终态完成)。 + backdate_direct_active_turn_for_test(&thread_id, DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS + 1); + let released = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ) - .expect("stale guard is released"); + .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") - .expect("a new turn can start once the stale guard is released"); - // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 - drop(first); - let still_running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("the newer turn survives the stale guard drop"); - assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); - drop(second); + // ④ "执行进程已退出"不看启动窗口:刚放行也允许释放。 + assert_eq!( + direct_stale_turn_for_release( + root.path(), + None, + DirectStaleTurnReleaseReason::ExecutorExited, + ) + .expect("executor exited ignores the start window"), + "client-turn-stale-1" + ); + drop(reservation); } + /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 #[test] - fn stale_guard_release_after_the_executor_exited_frees_the_project() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") - .expect("client turn"); - let released = release_stale_direct_taonier_active_invocation( + fn stale_turn_release_without_an_active_turn_says_so() { + let root = tempfile::tempdir().expect("empty turn root"); + let nothing = direct_stale_turn_for_release( root.path(), None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect("executor exited: this guard is residue"); - assert_eq!(released, "client-turn-exited-1"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - let _second = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") - .expect("a new turn can start after the residue is released"); - drop(first); - - // 没有任何登记时给出可读原因,而不是静默成功。 - let empty = tempfile::tempdir().expect("empty invocation root"); - let nothing = release_stale_direct_taonier_active_invocation( - empty.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, + DirectStaleTurnReleaseReason::ExecutorExited, ) .expect_err("nothing to release"); assert!(nothing.contains("没有正在运行"), "{nothing}"); } - /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 - fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { - let root = root.canonicalize().expect("canonical root"); - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .expect("active invocation lock"); - let entry = active.get_mut(&root).expect("registered invocation"); - entry.started_at = entry.started_at.saturating_sub(age_ms); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); @@ -6780,9 +6583,10 @@ mod tests { .expect("completed replay authority is the stable outer turn, not a resampled brief"); assert_eq!(completed_replay, persisted); - let active = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-completed-1") - .expect("restore the same client invocation after response loss"); + let _active_turn = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root.path()), + "client-turn-completed-1", + ); let replay = ensure_direct_taonier_art_package_at( root.path(), "第一套美术但偷偷换 brief", @@ -6799,7 +6603,6 @@ mod tests { .state, DirectTaonierRegenerationWorkflowState::Completed ); - drop(active); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index f3b844034..ea7dfb10d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -83,6 +83,19 @@ pub(crate) struct DirectActiveTurnSnapshot { pub(crate) sequence: u64, } +/// 一个 thread 上正在跑的那一轮的**只读身份**:给"这一轮是谁"的读者用(付费美术执行会话、 +/// 工具桥、校验预约、上下文预取)。 +/// +/// 身份取自 Thread Manager 的活动回合登记——"这一轮在不在跑"只有这一处真相,界面快照 +/// ([`list_direct_active_turns`])与放行闸门读的也是它,不再另立第二份记录。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DirectTurnIdentity { + /// 与事件 `turnId` 同一个身份(`clientTurnId`)。 + pub(crate) client_turn_id: String, + /// 这一轮被放行(登记占用)的时刻(Unix 毫秒)。 + pub(crate) started_at: u64, +} + /// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 /// /// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 @@ -314,6 +327,19 @@ impl DirectThreadManager { true } + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 + fn active_turn_identity(&self, thread_id: &str) -> Option { + self.threads.get(thread_id).and_then(|thread| { + thread + .active_turn + .as_ref() + .map(|active| DirectTurnIdentity { + client_turn_id: active.turn_id.clone(), + started_at: active.started_at, + }) + }) + } + fn turn_is_active(&self, thread_id: &str) -> bool { self.threads .get(thread_id) @@ -836,6 +862,16 @@ pub(crate) fn complete_direct_thread_turn_if_reserved( written } +/// 这个 thread 上正在跑的那一轮身份。 +/// +/// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 +pub(crate) fn read_direct_turn_identity(thread_id: &str) -> Option { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .active_turn_identity(thread_id) +} + /// 这个 thread 是否还有没收口的逻辑回合。 pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { global_direct_thread_manager() @@ -844,6 +880,24 @@ pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { .turn_is_active(thread_id) } +/// 测试用:把某 thread 上正在跑那一轮的登记时刻往前拨 `age_ms`。 +/// +/// 只服务"启动窗口闸门"这一条判据:终止的兜底释放只对"登记已存在很久"的残留放行, +/// 真实用例等不了 60 秒。 +#[cfg(test)] +pub(crate) fn backdate_direct_active_turn_for_test(thread_id: &str, age_ms: u64) { + let mut manager = global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(active) = manager + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + { + active.started_at = active.started_at.saturating_sub(age_ms); + } +} + fn notify_direct_thread_subscribers(thread_id: &str) { let subscriber_ids = { let manager = global_direct_thread_manager() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 93b0d5741..18ebdac42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -267,7 +267,7 @@ impl DirectToolBridge { impl DirectToolBridgeState { fn begin_user_turn(self: &Arc) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; + let turn_id = direct_active_turn_id_at(&self.root)?; let mut authorization = self .turn_authorization .lock() @@ -4665,9 +4665,10 @@ mod tests { let state = direct_tool_bridge_state(root.path().to_path_buf()); assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; - let _active_invocation = - DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) - .expect("client-owned stable invocation"); + let _active_invocation = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root.path()), + client_turn_id, + ); let active_turn = state .begin_user_turn() .expect("client turn authorization state"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28c506608..e89980666 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -3838,19 +3838,21 @@ mod tests { (handle, receiver) } - /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + /// 真实项目 + 真实工具桥 + 真实 Direct 回合占用(这一轮的调用身份)。 async fn tool_chain_start( root: &Path, ) -> ( super::super::direct_tool_bridge::DirectToolBridge, - DirectTaonierActiveInvocationGuard, + DirectTurnReservation, super::super::direct_tool_bridge::DirectExecutionTestFixture, ) { let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) .await .expect("start tool chain bridge"); - let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") - .expect("arm tool chain direct turn"); + let turn = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root), + "tool-chain-turn", + ); let execution = super::super::direct_tool_bridge::direct_execution_fixture(root, "tool-chain-turn") .await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs index 689298c53..734c5b852 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs @@ -2,7 +2,10 @@ //! //! 这个模块两件事,别再往里加第三件: //! 1. [`DirectTurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; -//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(取调用身份 → 落盘用户条目 → 下发 → 跑回合)。 +//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(落盘用户条目 → 下发 → 跑回合)。 +//! +//! 这一轮的**调用身份**(付费美术、执行会话、MCP、校验、上下文预取读的那个"这一轮是谁")由认领 +//! 在 Thread Manager 上登记,认领成功即成立;这里不再另取一次、也不再持第二份身份。 //! //! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 //! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 @@ -14,8 +17,8 @@ use super::{ append_direct_project_user_message_at, claim_direct_pending_turn, complete_direct_thread_turn_if_reserved, direct_thread_id_for_project, direct_tool_call_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, - DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTaonierActiveInvocationGuard, - DirectTurnError, DirectTurnTerminal, PendingDirectTurn, + DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, + PendingDirectTurn, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -88,7 +91,7 @@ impl Drop for DirectTurnReservation { /// /// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 /// **占用释放**(正常 / 失败 / panic 共用,见 [`DirectTurnReservation`] 的 `Drop`)、**中止路径** -/// (守卫被兜底释放时那一轮不会再有人替它收尾)。 +/// (兜底释放时那一轮不会再有人替它收尾)。 /// /// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; /// 这两种情况都会在下一次释放时再踢。 @@ -114,15 +117,6 @@ async fn run_dispatched_direct_turn( reservation: DirectTurnReservation, ) { let turn_id = dispatched.pending.client_turn_id.clone(); - // 调用身份整轮持有:付费美术、执行会话、MCP、校验、上下文预取都读它。这里拿不到身份说明这个 - // 项目上还有另一条调用没放(残留守卫 / 别处的入口),按回合失败说清原因;队列照常继续。 - let active_invocation = match DirectTaonierActiveInvocationGuard::enter(&root, &turn_id) { - Ok(guard) => guard, - Err(failure) => { - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); - return; - } - }; // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 if let Err(error) = append_direct_project_user_message_at(&root, &dispatched.pending.canonical_user_item) @@ -168,9 +162,6 @@ async fn run_dispatched_direct_turn( reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); } } - // 调用身份先放,再让占用对象的 `Drop` 收尾并踢下一脚:顺序反过来就会出现"守卫还握着、队首却已经 - // 可以放行"的窗口——那一脚会被自己的守卫挡成一条假的回合失败。 - drop(active_invocation); } #[cfg(test)] @@ -354,4 +345,45 @@ mod tests { assert!(direct_thread_turn_is_active(&busy)); drop(reservation); } + + /// 前置判据:删掉调用身份守卫之后,"回合任务泄漏、app-server 侧没有可中断句柄"的兜底释放 + /// 仍要能解开占用并把队首放行出去——`d833ca9d3` 的"重进会话被堵死"不得复活。 + #[test] + fn stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn() { + let thread = unique_thread("stale-cancel"); + let subscription = watch(&thread); + // 这一轮已经被放行(占用登记在 Thread Manager 上),但任务泄漏:拿不到可中断句柄。 + let leaked = DirectTurnReservation::accept_for_test(&thread, "turn-stale-1"); + enqueue(&thread, "turn-stale-2"); + + // "从没进执行器"只对过了启动窗口的残留放行;真实用例等不了 60 秒。 + crate::agent::backdate_direct_active_turn_for_test(&thread, 61_000); + let view = + crate::agent::cancel_direct_codex_turn_at(Path::new(&thread), Some("turn-stale-1")) + .expect("stale release"); + assert_eq!( + view.outcome, + super::super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_RELEASED + ); + assert_eq!(view.client_turn_id, "turn-stale-1"); + + // 占用解开:第一条被兜底收口,第二条接上——终止这一脚就是"继续放行"。 + let events = pending(&subscription); + assert!( + events.iter().any(|event| matches!( + event, + DirectThreadEvent::TurnCompleted { status, .. } if status == "aborted" + )), + "{events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + DirectThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-stale-2:user") + )), + "队首必须在兜底释放之后被放行:{events:?}" + ); + drop(leaked); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 33b849435..2cffc419b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -368,10 +368,10 @@ async fn start_for_current_turn( key: String, build: bool, ) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(root)?; + let turn_id = direct_active_turn_id_at(root)?; let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&root)? != turn_id { + if direct_active_turn_id_at(&root)? != turn_id { return Err("validation-turn-changed: 当前验证所属回合已结束".into()); } let session = super::direct_execution::current(&root)?; From 4e8b269816a75ee1608d790a054f5f12473e4e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:39:53 +0800 Subject: [PATCH 31/60] =?UTF-8?q?=E5=88=A0=E6=8E=89=20TurnAlreadyRunning?= =?UTF-8?q?=EF=BC=9A=E5=85=A5=E9=98=9F=E5=8C=96=E5=90=8E=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=92=9E=E5=8D=95=E7=9A=84=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删 DirectTurnError::TurnAlreadyRunning 变体,以及 is_reportable / Display 的对应分支与专属用例 - 前端 directTurnEnqueueFailureNotice 删掉 turnAlreadyRunning 分支;ts-rs 绑定同步删除该变体 - 结构化载荷用例的样本换成 queueFull,本地说明用例的文案样本换成队列已满提示 --- .../src-tauri/src/agent/direct_turn_error.rs | 50 ------------------- .../conversation/directCodexConversation.ts | 1 - .../chat/generated/DirectTurnError.ts | 5 -- .../appSurface/project-conversation.suite.ts | 18 +++---- .../tests/directTurnPresentation.test.ts | 4 +- 5 files changed, 9 insertions(+), 69 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 774fa34aa..80144ee12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -390,13 +390,6 @@ pub(crate) enum DirectTurnError { ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, - /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 - /// - /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 - TurnAlreadyRunning { - existing_invocation_id: String, - incoming_invocation_id: String, - }, /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 /// /// 上限只落在宿主这一处(`MAX_PENDING_DIRECT_TURNS`),随载荷带出去,界面不自己数一份。 @@ -487,7 +480,6 @@ impl DirectTurnError { Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } | Self::QueueFull { .. } - | Self::TurnAlreadyRunning { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 | Self::ProjectRootUnanchored { .. } @@ -653,23 +645,6 @@ impl fmt::Display for DirectTurnError { formatter, "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" ), - Self::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } => { - // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 - // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 - if existing_invocation_id == incoming_invocation_id { - formatter.write_str( - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", - ) - } else { - write!( - formatter, - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) - } - } Self::ProjectRootUnanchored { cause } => { write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") } @@ -1022,11 +997,6 @@ mod tests { policy_detail: "项目权限策略拒绝执行:conversation.write".into(), } .is_reportable()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - } - .is_reportable()); // 回合级失败在上游已经写过诊断。 assert!( !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") @@ -1034,26 +1004,6 @@ mod tests { ); } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 - #[test] - fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { - let same = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - }; - assert!(same.to_string().contains("同一轮消息仍在处理中")); - let different = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - }; - assert!(different - .to_string() - .contains("另一条 Direct 客户端回合正在运行")); - assert!(!different - .to_string() - .starts_with("direct-codex-turn-already-running:")); - } - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 #[test] fn boundary_serialization_uses_display() { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 62d80f344..bb1231450 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -102,7 +102,6 @@ export function directTurnEnqueueFailureNotice( case 'clientTurnIdMissing': case 'clientTurnIdMalformed': case 'queueFull': - case 'turnAlreadyRunning': case 'projectRootUnanchored': case 'projectRootUnusable': case 'permissionRejected': diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts index 94d69d66a..22c445d40 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -9,11 +9,6 @@ import type { DirectTurnDeadline } from './DirectTurnDeadline'; export type DirectTurnError = | { type: 'clientTurnIdMissing' } | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } - | { - type: 'turnAlreadyRunning'; - existingInvocationId: string; - incomingInvocationId: string; - } | { type: 'queueFull'; limit: number } | { type: 'projectRootUnanchored'; cause: string } | { type: 'projectRootUnusable' } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 45745ed82..18ce02538 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -23,19 +23,15 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 - const concurrent = { - error: { - type: 'turnAlreadyRunning' as const, - existingInvocationId: 'turn-1', - incomingInvocationId: 'turn-1', - }, - message: '同一轮消息仍在处理中', + const queueFull = { + error: { type: 'queueFull' as const, limit: 5 }, + message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', }; - expect(readDirectTurnEnqueueFailure(concurrent)?.error.type).toBe( - 'turnAlreadyRunning', + expect(readDirectTurnEnqueueFailure(queueFull)?.error.type).toBe( + 'queueFull', ); - expect(directTurnEnqueueFailureNotice(concurrent)).toBe( - '同一轮消息仍在处理中', + expect(directTurnEnqueueFailureNotice(queueFull)).toBe( + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ); // 认得的参数 / 前置条件类都给同级提示。 expect( diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 06ce3bd38..7dbe863b7 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -356,7 +356,7 @@ describe('DirectProject 聊天分区', () => { entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')], localMessages: [ localNotice( - '同一轮消息仍在处理中', + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', 'direct-codex:turn-9:user:rejected', ), ], @@ -370,7 +370,7 @@ describe('DirectProject 聊天分区', () => { expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); expect(turns[1]?.finals.map((block) => block.text)).toEqual([ - '同一轮消息仍在处理中', + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ]); }); From 354acfd70b6ba0addf6aa166e25fb3048bf73909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:41:10 +0800 Subject: [PATCH 32/60] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=AE=88=E5=8D=AB=E6=B8=85=E7=90=86=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=8CADR=20=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 状态改为 2026-09-30 落地完成,§9 守卫退役条目改成已落地口径 - 实施计划状态改为第 0–5 步全部完成,进度表第 5 步写成落地说明;第 4 步的 TurnAlreadyRunning 交还给第 5 步 - 09-23 接单化计划加后续注记:接单/拒单词表与 TurnAlreadyRunning 已退役,正文不再回改 - decision-log 的 09-30 条目补落地与验证结果,09-24 条目加落地指向 --- ...ct命令入队化与待发消息队列归宿主-2026-09-24.md | 5 +++-- .../shared-memory/decision-log.md | 17 +++++++++++------ ...roject命令入队化与待发消息队列归宿主-2026-09-24.md | 8 ++++---- ...ž施计划】DirectProject命令接单化-2026-09-23.md | 3 +++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 90d8b2735..569887ae5 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -1,6 +1,6 @@ # 【ADR】DirectProject命令入队化与待发消息队列归宿主 -状态:已接受(2026-09-24 设计定稿;**尚未落地**,实施顺序与验收见 +状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**,实施顺序与验收见 [`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) ## 背景 @@ -87,7 +87,8 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:五个读者 (`direct_execution` / `direct_tool_bridge` / `direct_validation` / `direct_project_context` / 付费美术重生成) - 改读 Thread Manager 的活动回合身份,守卫连同它的测试一起删,不再有第二份进程内记录。 + 已改读 Thread Manager 的活动回合身份(`direct_active_turn_id_at`),守卫连同它的测试一起删掉,不再有第二份 + 进程内记录。 - 删守卫的前置判据**已核实(2026-09-30)**,不需要新增"取消路径补无条件终态":`d833ca9d3` 的兜底路径本来就 同时做三件事——释放占用、往事件流补一条 `turn.completed{aborted}`(否则前端会永远停在运行中)、调 `complete_direct_thread_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 66dc906c3..118f06d08 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9609,6 +9609,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 + **(落地,2026-09-30)**:见下方 09-30 条,守卫已删、`TurnAlreadyRunning` 已删,60 秒闸门改挂 + Thread Manager 的占用登记年龄。 - 代价:入队即写盘(引用 UI 设计文档的条目会生成 `ui/generated-.js`,取消不撤);检查是时间点事实、 放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化; 失去"真实二进制驱动执行层生产验证"这条手工路径。 @@ -9629,9 +9631,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `NeverReachedExecutor` 时校验年龄(`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`),返回身份;释放本身仍由 既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成(天然幂等)。 - 决策:连接死亡的失败事实由 app-server 连接层自己落地(`ab970b9fd`),与守卫无关,删除守卫不动那条链路。 -- 影响范围:`agent/direct_runtime/mod.rs`(删守卫表 / 只读探测,改为 `direct_active_turn_id_at`)、 - `agent/direct_thread_manager.rs`(`DirectTurnIdentity` + `read_direct_turn_identity`)、 - `agent/codex_app_server/mod.rs`(兜底目标改 `DirectStaleTurnReleaseReason`)、五个身份读者与相关测试、 - 删 `DirectTurnError::TurnAlreadyRunning` 与前端对应分支 / 生成绑定。 -- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`;`npx vitest run tests/appSurface.test.ts`; - `npm --workspace apps/ai-game-creator-shell run typecheck`;`npm run check:encoding`;`git diff --check`。 +- 落地(`direct_runtime/mod.rs` 删守卫表与只读探测,改 `direct_active_turn_id_at`;`direct_thread_manager.rs` + 补 `DirectTurnIdentity` + `read_direct_turn_identity`;`codex_app_server/mod.rs` 兜底目标改 + `DirectStaleTurnReleaseReason` 并接 `direct_stale_turn_for_release`;五个身份读者与相关测试改写; + `direct_turn_dispatch.rs` 不再另取调用身份;删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定)。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`(370 passed);新增 + `stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn` 与两条 `direct_stale_turn_for_release` + 单测;`npx vitest run tests/appSurface.test.ts`(214 passed / 9 skipped)、`directThreadChat` / + `directTurnPresentation` / `project-conversation` 定向用例、`npm --workspace apps/ai-game-creator-shell run typecheck`、 + `npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 8a7dc67f6..ff038a336 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,7 +2,7 @@ 更新时间:`2026-09-24` -状态:**实施中**(第 0–4 步已落地;第 5 步进行中) +状态:**已落地**(第 0–5 步全部完成,2026-09-30) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -16,7 +16,7 @@ | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | -| 第 5 步 守卫清理 | 进行中 | 前置判据已核实;改读 Thread Manager 活动回合身份、删调用身份守卫与其测试,并删 `TurnAlreadyRunning` | +| 第 5 步 守卫清理 | 已落地 | 五个身份读者改读 `direct_active_turn_id_at`;删 `DirectTaonierActiveInvocationGuard` / `DirectActiveTurnView` / 只读探测与 `release_stale_direct_taonier_active_invocation`(改 `direct_stale_turn_for_release` 只做前置校验);删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定;补前置判据用例 | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -145,8 +145,8 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - `agent/direct_runtime/mod.rs`:删 `run_direct_game_creator_turn_at` 与 `run_direct_game_creator_turn_at_with_creation_type` (各自只有彼此与 CLI 一个调用方)。 - 删 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(CLI 的唯一消费者)。 -- 删 `DirectTurnError::TurnAlreadyRunning`(两个生产点都没了)与前端"同一轮消息仍在处理中"文案、专属分支、 - `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- `DirectTurnError::TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"文案、专属分支、`project-conversation.suite.ts` + 的对应断言**挪到第 5 步**(它最后一个生产点在调用身份守卫里)。 - 文档同步:09-22 里程碑把 `--direct-codex-chat` 从"范围外(保留)"改成退役项;两份 Direct 技术方案的 CLI 承诺删掉; 09-23 ADR 与 `decision-log.md` 里"CLI 保持 await"的口径改掉。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 4724c1822..989095efb 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -4,6 +4,9 @@ 状态:**四步全部落地**。 +后续:09-24 起命令边界改叫「入队 / 入队失败 / 放行」,本文里的「接单 / 拒单」与 `TurnAlreadyRunning` +都已退役(见 09-24 ADR 与 09-30 决策记录);正文保留当时口径,不再回改。 + 设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 From 3e8954548ac7b51234b42f9182b856486c356bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:33:42 +0800 Subject: [PATCH 33/60] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9B=BF=E8=BA=AB?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=AE=BF=E4=B8=BB=E7=9A=84=E6=8C=89=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=88=A4=E5=AE=9A=EF=BC=9AalreadyDispatched=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E6=AD=A3=E5=9C=A8=E8=B7=91=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hostQueue 记录被放行的 clientTurnId,cancel 只在身份一致时回 alreadyDispatched,其余回 notFound - 与宿主 remove_pending_turn(按 active_turn.turn_id 比对)保持一致,避免复用该替身时掩盖 notFound 分支 --- .../tests/appSurface/chat-composer.suite.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 132940ace..3e6545351 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -244,11 +244,14 @@ function hostQueue( ) { const queued: Array<{ clientTurnId: string; userItem: unknown }> = []; let running = alreadyRunning; + /** 正在跑那一轮的回合身份:与宿主一致,`alreadyDispatched` 只认它(不是"随便哪一轮在跑")。 */ + let dispatchedClientTurnId: string | null = null; const dispatchHeadIfIdle = () => { if (running) return; const head = queued.shift(); if (!head) return; running = true; + dispatchedClientTurnId = head.clientTurnId; harness.emitDirectThreadEvents( { type: 'queue.removed', @@ -283,7 +286,11 @@ function hostQueue( const index = queued.findIndex( (item) => item.clientTurnId === clientTurnId, ); - if (index < 0) return running ? 'alreadyDispatched' : 'notFound'; + if (index < 0) { + return dispatchedClientTurnId === clientTurnId + ? 'alreadyDispatched' + : 'notFound'; + } queued.splice(index, 1); harness.emitDirectThreadEvents({ type: 'queue.removed', @@ -296,6 +303,7 @@ function hostQueue( /** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */ complete(status = 'completed') { running = false; + dispatchedClientTurnId = null; harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 }); dispatchHeadIfIdle(); }, From f4558475a0fe59a83b36bdc1bc689c74c892047b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:42:57 +0800 Subject: [PATCH 34/60] =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=97=B6=E6=B8=85=E6=8E=89=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=9F=8B=E7=82=B9=E5=8F=A5=E6=9F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制器在 `remove_direct_project_pending_turn` 返回 `removed` 时删除该 `clientTurnId` 的埋点句柄 - 被取消的待发消息永远不会放行、也就不会有自己的 `turn.completed`,句柄留着只会泄漏 - 留着还会挤进"身份缺失"的兜底结算候选(挑认领表里最早那一条),把下一次结算算到没跑过的回合头上 - 补一条 appSurface 用例钉住这条不变式:回放没有身份的失败终态时,兜底结算只落到还能收口的那一条 --- .../useDirectProjectChatController.ts | 6 ++ .../tests/appSurface/chat-composer.suite.ts | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 429c71343..91f86394e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -335,6 +335,12 @@ export function useDirectProjectChatController({ { projectPath: nextProjectPath, clientTurnId }, ); if (projectPathRef.current !== nextProjectPath) return; + if (outcome === 'removed') { + // 这一条不会再被放行,也就不会再有 `turn.completed` 来结算它:句柄只能在这里清掉。 + // 留着不但泄漏,还会挤进"身份缺失"的兜底结算的候选里(`settlePendingRunAnalytics` + // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 + pendingRunAnalyticsRef.current.delete(clientTurnId); + } setComposerNotice( outcome === 'notFound' ? '这条待发消息已经不在队列里' diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3e6545351..5f653440e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -647,6 +647,61 @@ export function registerChatComposerControlTests() { ).toEqual(['第一条消息', '第二条消息', '第三条消息']); }); + it('不再认领被取消那一条的埋点句柄:身份缺失的兜底结算只落到还能收口的那一条', async () => { + // 取消掉的待发消息永远不会被放行,也就永远不会有它自己的 `turn.completed`:句柄必须在取消时清掉。 + // 留着的话,"身份缺失"的兜底结算(认领表里**最早**那一条)会先认出它——取消一条消息等于把下一次 + // 结算算到一条根本没跑过的回合头上,真正跑着的那条反而没人结算。 + let host: ReturnType | null = null; + const { invoke, surface, harness } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), + }); + // 线程上已有一条(恢复出来的)回合在跑:发出去的两条都只会排队,事件流里也不会走 `turn.started`。 + host = hostQueue(harness, { running: true }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await setComposerText(composer, '会被取消的消息'); + submitComposerForm(composer); + await setComposerText(composer, '留下来的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(2); + }); + + fireEvent.click( + within(queue).getByRole('button', { + name: '取消待发消息 会被取消的消息', + }), + ); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(1); + }); + const attempts = invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => String(args?.analyticsAttemptId ?? '')); + expect(attempts).toHaveLength(2); + const settledAttemptIds = () => + invoke.mock.calls + .filter(([command]) => command === 'settle_direct_run_analytics') + .map(([, args]) => String(args?.attemptId ?? '')); + + // 回放一条**没有身份**的失败终态:订阅重建时的生命周期锚点就是这个形状(订阅在新回合开始前 + // 断过,开局那条 `turn.started` 不在手里)。兜底结算此刻只能按"认领表里最早那一条"认人—— + // 被取消的那条若还留在表里,就会顶替掉真正该结算的那一条。 + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'failed', + failure: { message: '重连之前那一轮已经失败' }, + at: 4, + }); + }); + await waitFor(() => { + expect(settledAttemptIds()).toHaveLength(1); + }); + expect(settledAttemptIds()).toEqual([attempts[1]]); + }); + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 const { invoke, surface, harness } = await openDirectCodexSurface({ From 20126ead0c3ea5ff86a1dc8cd211a4ebc6e635aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:43:21 +0800 Subject: [PATCH 35/60] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E7=94=A8=E8=AF=8D?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=9A=E5=85=A5=E9=98=9F=E5=8D=8A=20/=20?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E5=8D=8A=20=E6=94=B9=E6=88=90=20=E5=85=A5?= =?UTF-8?q?=E9=98=9F=E4=BE=A7=20/=20=E6=94=BE=E8=A1=8C=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `codex_app_server/mod.rs` 两处、`direct_runtime/mod.rs`、`direct_thread_queue.rs`、`direct_runtime/user_input.rs` 各处的简体表述统一 - 同步实施计划文档里的同一处措辞,保持词汇一致 - 只改措辞,不改任何行为 --- .../src-tauri/src/agent/codex_app_server/mod.rs | 4 ++-- .../src-tauri/src/agent/direct_runtime/mod.rs | 2 +- .../src-tauri/src/agent/direct_runtime/user_input.rs | 4 ++-- .../src-tauri/src/agent/direct_thread_queue.rs | 2 +- ...¡划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 4af07737d..2c3ada2fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3300,7 +3300,7 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由放行半在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; + // 用户条目由放行侧在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: @@ -8204,7 +8204,7 @@ done bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行(认领时登记调用身份), + // 生产入口(`enqueue_direct_codex_turn` + 放行侧)在起 codex 之前先放行(认领时登记调用身份), // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 83040048a..964593cdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -4277,7 +4277,7 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( /// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在入队半调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队侧调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2ea1be4d5..f0feff606 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,7 +30,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 入队半就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt +/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt /// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 /// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// @@ -64,7 +64,7 @@ pub(crate) async fn enqueue_direct_codex_turn( .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 入队半:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: /// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 /// /// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs index 18425b202..d06608f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -92,7 +92,7 @@ pub(crate) enum EnqueueOutcome { AlreadyKnown, } -/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。 +/// 入队被检查挡下来的原因。满队之外的原因由入队侧自己的 typed 错误表达。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum EnqueueRejection { /// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index ff038a336..4d223ef68 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -64,8 +64,8 @@ 改动点: - `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 - **入队半**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; - 任何一步失败返回 typed 入队失败。**放行半**(第 2 步)从占用登记起。 + **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。**放行侧**(第 2 步)从占用登记起。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From 25cec7955a8814d1f61d0d7f72dbda61dc15697a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:44:36 +0800 Subject: [PATCH 36/60] =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=94=B9=E6=88=90=E6=99=AE=E9=80=9A=E5=88=86?= =?UTF-8?q?=E6=94=AF=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=B5=8C=E5=A5=97=E4=B8=89?= =?UTF-8?q?=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `cancelPendingTurn` 里三路结果(不在队 / 已放行 / 已取消)改用 if / else 表达 - 已放行那一路的说明注释挪到对应的分支上 - 不改任何文案与行为 --- .../useDirectProjectChatController.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 91f86394e..86f0238cc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -341,14 +341,17 @@ export function useDirectProjectChatController({ // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 pendingRunAnalyticsRef.current.delete(clientTurnId); } - setComposerNotice( - outcome === 'notFound' - ? '这条待发消息已经不在队列里' - : // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 - outcome === 'alreadyDispatched' - ? '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」' - : '', - ); + if (outcome === 'notFound') { + setComposerNotice('这条待发消息已经不在队列里'); + } else if (outcome === 'alreadyDispatched') { + // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 + setComposerNotice( + '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」', + ); + } else { + // 取消成功不占用说明行:chip 由事件投影撤掉,用户看到它消失本身就是结果。 + setComposerNotice(''); + } } catch (error) { if (projectPathRef.current !== nextProjectPath) return; setComposerNotice( From a83ed01313463d8caa6db22661d39523999c44e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:45:44 +0800 Subject: [PATCH 37/60] =?UTF-8?q?=E5=88=A0=E6=8E=89=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=B8=94=E6=B0=B8=E4=B8=8D=E6=88=90=E7=AB=8B=E7=9A=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=88=87=E6=8D=A2=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runTurn` 的 catch 里同一判据写了两遍,两处之间只有同步代码、没有 await,`projectPathRef.current` 不可能变 - 保留 await 之后那一遍,删掉其后永远不会成立的第二遍 - 不改行为:守卫效果由前一处完整覆盖 --- .../chat/controller/useDirectProjectChatController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 86f0238cc..4bbcc0f97 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -609,7 +609,6 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return true; // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 // 用户以为没发出去、再按一次。 draftSafe = true; From 75f9bbacb1f6d806d3eb3225449a6038ef069ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:50:38 +0800 Subject: [PATCH 38/60] =?UTF-8?q?=E5=85=A5=E9=98=9F=E5=89=8D=E5=85=88?= =?UTF-8?q?=E5=88=A4=E5=AE=B9=E9=87=8F=EF=BC=9A=E6=BB=A1=E4=BA=86=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=81=9A=E5=B7=A5=E7=A8=8B=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `direct_thread_manager` 新增只读的 `direct_pending_turn_count`(管理器 `pending_turn_count` + 模块级封装) - `enqueue_direct_codex_turn_typed` 在前置条件之后、工程准备之前先做一次容量预判,满队直接返回 `QueueFull` - 工程准备是分钟级、会在磁盘留产物的活,为注定收不下的消息先做它没有意义;权威判据仍是入队临界区里的检查 - 补一条管理器用例钉住这个只读口径:只数在队条目,认领 / 取消后立刻跟着变 --- .../src/agent/direct_runtime/user_input.rs | 22 +++++--- .../src/agent/direct_thread_manager.rs | 53 +++++++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 5 +- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index f0feff606..d52806b46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,8 +31,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// /// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt -/// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 -/// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 +/// 投影 → 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 +/// 之后只做一件事——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// /// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 /// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `direct_turn_dispatch`), @@ -65,11 +65,12 @@ pub(crate) async fn enqueue_direct_codex_turn( } /// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: -/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队。 /// /// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; -/// 没通过检查的东西不进队列。入队本身只做判重与容量,不再有第二种副作用——落盘、回合事件与整轮 -/// 都在放行那一侧。 +/// 没通过检查的东西不进队列。容量放在工程准备之前是"别为注定收不下的消息先付分钟级的代价",它 +/// 与入队那一刻的容量检查同源,只是提前一次。入队本身只做判重与容量,不再有第二种副作用——落盘、 +/// 回合事件与整轮都在放行那一侧。 async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, @@ -92,6 +93,16 @@ async fn enqueue_direct_codex_turn_typed( let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; + let thread_id = direct_thread_id_for_project(root); + // 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程 + // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 + // 临界区里的容量检查(下面 `enqueue_direct_pending_turn`):这里只是快速失败,中间被别人的消息 + // 挤满时那一条照样拦得住。 + queue_has_room(direct_pending_turn_count(&thread_id)).map_err(|_| { + DirectTurnError::QueueFull { + limit: MAX_PENDING_DIRECT_TURNS, + } + })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -112,7 +123,6 @@ async fn enqueue_direct_codex_turn_typed( .map_err(|error| DirectTurnError::InputRejected { detail: error.to_string(), })?; - let thread_id = direct_thread_id_for_project(root); match enqueue_direct_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index ea7dfb10d..2e1fe0208 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -570,6 +570,14 @@ impl DirectThreadManager { .unwrap_or_default() } + /// 当前在队的待发消息条数(容量预判用,只读)。 + fn pending_turn_count(&self, thread_id: &str) -> usize { + self.threads + .get(thread_id) + .map(|thread| Self::pending_turns(thread).len()) + .unwrap_or(0) + } + /// 观察一条事件,返回它本身是否可回收。 fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { match event { @@ -775,6 +783,17 @@ pub(crate) fn enqueue_direct_pending_turn( outcome } +/// 这个 thread 上**在队**的待发消息条数(只读)。 +/// +/// 给入队侧做最便宜的一次容量预判用:满了就先拒绝,不去做工程准备那种分钟级、会在磁盘上留下产物的 +/// 活。权威判据仍然是入队临界区里的 `queue_has_room`,这里只求早失败。 +pub(crate) fn direct_pending_turn_count(thread_id: &str) -> usize { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pending_turn_count(thread_id) +} + /// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 pub(crate) fn remove_direct_pending_turn( thread_id: &str, @@ -1462,6 +1481,40 @@ mod tests { ); } + /// 入队侧拿来做容量预判的条数与权威判据同源:只数在队条目,认领 / 取消之后立刻跟着变。 + #[test] + fn pending_turn_count_tracks_only_the_queue() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!(manager.pending_turn_count("thread-unknown"), 0); + for index in 0..MAX_PENDING_DIRECT_TURNS { + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"); + } + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS + ); + assert_eq!( + queue_has_room(manager.pending_turn_count("thread-1")), + Err(EnqueueRejection::QueueFull) + ); + + // 认领队首与取消一条在队消息都立刻反映到这条只读口径上。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS - 1 + ); + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS - 2 + ); + } + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 #[test] fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 4d223ef68..5a9d29642 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -64,8 +64,9 @@ 改动点: - `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 - **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; - 任何一步失败返回 typed 入队失败。**放行侧**(第 2 步)从占用登记起。 + **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。容量预判只是一次提前的快速失败,权威判据仍在入队临界区里(工程准备是分钟级、会 + 在磁盘留产物的活,满了就不该先做它)。**放行侧**(第 2 步)从占用登记起。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From d341a9be1339a981820ed5ae2c9f3e1121a010d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:43:20 +0800 Subject: [PATCH 39/60] =?UTF-8?q?=E8=A1=A5=E4=B8=AD=E9=80=94=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E8=80=85=E7=9A=84=E5=90=88=E5=B9=B6=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `late_subscriber_sees_the_running_turn_and_the_pending_queue_together` - 钉住:正在跑那一轮的 `turn.started` 锚点(带开口条目身份)与还排着的 `queue.enqueued` 同时进 bootstrap,顺序按事件序 - 原有用例各覆盖一半(一条只测队列、一条只测锚点),这条覆盖两者同时存在的中途加入场景 --- .../src/agent/direct_thread_manager.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2e1fe0208..17375996e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -1422,6 +1422,44 @@ mod tests { assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); } + /// 中途加入者拿到的是**合并快照**:正在跑那一轮的锚点 + 还没走的队列,顺序就是事件顺序。 + /// + /// 队列搬到宿主之后这件事只能由 live-set bootstrap 顶上:另一个窗口、离开工作台再回来, + /// 看到的状态必须和一直在场的那位一致——在跑的那一轮还在跑(锚点带开口条目身份,条目挂得回去), + /// 排着的那几条还在队,而不是只剩其中一个。 + #[test] + fn late_subscriber_sees_the_running_turn_and_the_pending_queue_together() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert!( + matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted { .. }, + DirectThreadEvent::QueueEnqueued { client_turn_id, .. } + ] if client_turn_id == "turn-2" + ), + "中途加入者要先看到在跑的那一轮、再看到还排着的那几条:{:?}", + bootstrap.events + ); + assert_eq!( + bootstrap.events[0].user_item_id(), + Some("direct-codex:turn-1:user"), + "锚点必须带着正在跑那一轮的开口条目身份" + ); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 #[test] fn enqueue_is_idempotent_per_client_turn_id() { From e0ca5ad9b93d8c3978cddf9f336586527a40dcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:43:32 +0800 Subject: [PATCH 40/60] =?UTF-8?q?=E5=89=8D=E7=AB=AF=20bootstrap=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E8=A1=A5=E9=BD=90=EF=BC=9A=E5=9C=A8=E8=B7=91?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E8=BD=AE=E4=B8=8E=E5=9C=A8=E9=98=9F?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E5=90=8C=E6=97=B6=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `directThreadChat.test.ts` 队列投影分组里新增一条:bootstrap 同时带 `turn.started` 锚点与两条 `queue.enqueued` - 断言 `turnRunning` / `turnUserItemId` / `pendingTurns` 三者同时正确,而不是各测一半 - 只加用例,不改实现 --- .../tests/directThreadChat.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index ceb68ede4..bf252baff 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -410,6 +410,32 @@ describe('DirectProject 聊天 reducer', () => { [{ type: 'input_text', text: '第二条' }], ]); }); + + it('bootstrap 同时交出在跑的那一轮与在队的那几条:顺序就是事件顺序', () => { + // 中途加入(另一个窗口、离开工作台再回来)拿到的是一份**合并快照**:锚点让"这一轮还在跑" + // 成立,后面的在队条目让队列不丢。只有其中一个都算把界面还原成了错的样子。 + const bootstrap = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_500 }), + 'direct-codex:client-0:user', + ), + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + ], + } as never, + ); + expect(bootstrap.turnRunning).toBe(true); + expect(bootstrap.turnUserItemId).toBe('direct-codex:client-0:user'); + expect(bootstrap.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + ]); + }); }); describe('失败终态(turn.completed 带 failure 载荷)', () => { From 51e48113445ef3134e11331edef7cbbd5efe815b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:55:07 +0800 Subject: [PATCH 41/60] =?UTF-8?q?=E6=AE=8B=E7=95=99=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E9=87=8A=E6=94=BE=E6=94=B9=E6=88=90=20Thread?= =?UTF-8?q?=20Manager=20=E9=87=8C=E7=9A=84=E5=8E=9F=E5=AD=90=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DirectThreadManager::release_stale_turn` 在同一个临界区里做完"校验 → 解除占用 → 写 aborted 终态" - 判据不满足(没有活动回合 / 身份对不上 / 没过启动窗口)一个字都不写,占用与事件都保持原样 - 拆成"先校验后释放"两次取锁会让并发认领插进中间,清掉新那一轮的占用并给老回合补假终态 - `direct_stale_turn_for_release` 降级成"调用原子出口 + 把结果翻成人话",文案与对外语义不变 - 兜底终态改由 Thread Manager 写,身份取被释放那一轮自己的 clientTurnId,删掉 app-server 侧的建事件助手 - 测试:管理器侧新增"校验与释放是一个动作"用例;运行时侧用例补上"拒绝时零写入 / 通过时占用与终态一起落地" --- .../src/agent/codex_app_server/mod.rs | 52 +----- .../src-tauri/src/agent/direct_runtime/mod.rs | 94 +++++++--- .../src/agent/direct_thread_manager.rs | 168 +++++++++++++++++- 3 files changed, 237 insertions(+), 77 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 2c3ada2fc..030f71631 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -4465,14 +4465,9 @@ enum DirectCodexTurnCancelTarget { /// [`direct_stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, /// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 [`direct_stale_turn_for_release`] 返回的 -/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 -/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 -fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { - DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) - .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) -} - +/// 兜底终态由 Thread Manager 在解除占用的同一个临界区里写下(带 `userItemId`,身份取被释放那一轮 +/// 自己的 `clientTurnId`,与放行时 `turn.started` 同一份 canonical 口径):这一轮不会再有原生终态, +/// 猜一个身份会让前端把边界盖到别人身上。 pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -4523,16 +4518,10 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - // 先做前置校验:确实登记着一轮、身份对得上、过了启动窗口才允许判成残留。 + // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; + // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 + // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 let released = direct_stale_turn_for_release(root, client_turn_id, reason)?; - // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。走 Thread Manager 的 - // 深层出口而不是裸 append:这也是**解除占用的唯一出口**,解除不了这个 thread 会一直 - // 被认为是"还有没收口的回合",挡住后面的放行。 - complete_direct_thread_turn( - &direct_thread_id_for_project(root), - direct_stale_cancel_turn_completed_event(&released), - ); // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_direct_queue_dispatch(root); @@ -6101,35 +6090,6 @@ mod tests { ); } - /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId - /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 - #[test] - fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { - let event = direct_stale_cancel_turn_completed_event("turn-0001"); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); - assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); - assert!(matches!( - event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" - )); - - for missing in ["", " "] { - let event = direct_stale_cancel_turn_completed_event(missing); - assert_eq!( - event.user_item_id(), - None, - "拿不到 clientTurnId 时不得编造开口条目身份" - ); - } - - // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 - assert_eq!( - direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); - } - /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 /// /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 964593cdf..64c696479 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -496,7 +496,8 @@ impl DirectStaleTurnReleaseReason { } } -/// 兜底释放前的前置校验:这一轮是不是**可以**被判成残留,返回它的 `clientTurnId`。 +/// 兜底释放一个残留回合:校验与释放**在 Thread Manager 的同一个临界区里**完成,返回被释放的 +/// `clientTurnId`。 /// /// 条件(三条必须同时成立,这段注释就是契约): /// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); @@ -504,34 +505,33 @@ impl DirectStaleTurnReleaseReason { /// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 /// [`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// **释放本身不在这里**:调用方拿到身份后往 Thread Manager 写一条 `turn.completed{aborted}`, -/// 那才是解除占用的唯一出口(也因此天然幂等——占用已经没了就返回"没有正在运行的回合")。 +/// 不满足任何一条都**什么都不写**(占用、事件都不变),返回可读原因;因此它天然幂等——占用已经 +/// 没了就返回"没有正在运行的回合"。这一层只负责把 [`DirectStaleTurnRelease`] 翻成人话:判据与释放 +/// 动作都在 Thread Manager 里,因为它们必须在同一个临界区里(拆成两步会让并发认领插进中间,见 +/// [`release_stale_direct_turn`])。 pub(crate) fn direct_stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, reason: DirectStaleTurnReleaseReason, ) -> Result { - let thread_id = direct_thread_id_for_project(root); - let active = read_direct_turn_identity(&thread_id) - .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; - if let Some(expected) = expected_client_turn_id + let expected = expected_client_turn_id .map(str::trim) - .filter(|value| !value.is_empty()) - { - if active.client_turn_id != expected { - return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + .filter(|value| !value.is_empty()); + let min_age_ms = (reason == DirectStaleTurnReleaseReason::NeverReachedExecutor) + .then_some(DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS); + match release_stale_direct_turn(&direct_thread_id_for_project(root), expected, min_age_ms) { + DirectStaleTurnRelease::Released(client_turn_id) => Ok(client_turn_id), + DirectStaleTurnRelease::NoActiveTurn => { + Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()) } - } - if reason == DirectStaleTurnReleaseReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(active.started_at); - if age_ms < DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS { - return Err(format!( - "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", - age_ms / 1000 - )); + DirectStaleTurnRelease::AnotherTurnRunning => { + Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()) } + DirectStaleTurnRelease::WithinStartupWindow { age_ms } => Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )), } - Ok(active.client_turn_id) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -5542,12 +5542,15 @@ mod tests { assert!(direct_active_turn_id_at(root.path()).is_err()); } - /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口。 + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口;判据不过**一个字都不写**, + /// 通过则占用与兜底终态在同一个临界区里一起落地。 #[test] fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { let root = tempfile::tempdir().expect("stale turn root"); let thread_id = direct_thread_id_for_project(root.path()); let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); + let watcher = subscribe_direct_thread(&thread_id); + let _ = consume_direct_thread(&watcher.subscription_id).expect("drain bootstrap"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 let mismatch = direct_stale_turn_for_release( @@ -5557,10 +5560,6 @@ mod tests { ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert_eq!( - read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), - Some("client-turn-stale-1".to_string()) - ); // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 let young = direct_stale_turn_for_release( @@ -5571,7 +5570,20 @@ mod tests { .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 老过窗口:判成残留,调用方拿到身份(解除占用由调用方写终态完成)。 + // ① ② 两条拒绝路径都**什么都没写**:占用还在,事件流上也没有任何东西。 + assert_eq!( + read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) + ); + assert!( + consume_direct_thread(&watcher.subscription_id) + .expect("consume after rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // ③ 老过窗口:判成残留——占用与兜底终态在同一个临界区里一起落地。 backdate_direct_active_turn_for_test(&thread_id, DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS + 1); let released = direct_stale_turn_for_release( root.path(), @@ -5580,7 +5592,33 @@ mod tests { ) .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - // ④ "执行进程已退出"不看启动窗口:刚放行也允许释放。 + assert_eq!( + read_direct_turn_identity(&thread_id), + None, + "占用必须一起解除" + ); + let events = consume_direct_thread(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + let terminal = &events[0]; + assert!( + matches!( + terminal, + DirectThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ), + "{terminal:?}" + ); + assert_eq!( + terminal.user_item_id().as_deref(), + Some("direct-codex:client-turn-stale-1:user"), + "兜底终态的身份取被释放那一轮自己的 clientTurnId" + ); + assert!(terminal.at().is_some(), "兜底终态仍要带宿主观测时间"); + + // ④ 释放之后这个 thread 立刻腾出来了(占用解除与终态同批落地):下一条能重新认领, + // 而且"执行进程已退出"不看启动窗口——刚放行也允许释放。 + let _next = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-2"); assert_eq!( direct_stale_turn_for_release( root.path(), @@ -5588,7 +5626,7 @@ mod tests { DirectStaleTurnReleaseReason::ExecutorExited, ) .expect("executor exited ignores the start window"), - "client-turn-stale-1" + "client-turn-stale-2" ); drop(reservation); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 17375996e..017d41edd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -9,9 +9,10 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_tool_call_now_ms, queue_has_room, DirectQueueRemovalOutcome, DirectQueueRemovalReason, - DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, - EnqueueOutcome, EnqueueRejection, PendingDirectTurn, + direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room, + DirectQueueRemovalOutcome, DirectQueueRemovalReason, DirectThreadConsumeResult, + DirectThreadEvent, DirectThreadSubscriptionBootstrap, EnqueueOutcome, EnqueueRejection, + PendingDirectTurn, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -96,6 +97,22 @@ pub(crate) struct DirectTurnIdentity { pub(crate) started_at: u64, } +/// 兜底释放一个残留回合的结果。 +/// +/// 除了 [`Self::Released`],其余三种都是**什么都没写**:占用没动、事件没发。调用方据此给用户 +/// 不同的话;把"校验"和"释放"拆成两步就会丢掉这个性质(见 [`DirectThreadManager::release_stale_turn`])。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DirectStaleTurnRelease { + /// 占用已解除,`turn.completed{aborted}` 已下发:这个 thread 现在可以放行下一条。 + Released(String), + /// 这个 thread 上没有未收口的回合。 + NoActiveTurn, + /// 登记的回合身份与调用方给的不一致:绝不误伤另一条回合。 + AnotherTurnRunning, + /// 登记的年龄还没过启动窗口(`age_ms` 是登记到 `now_ms` 的差)。 + WithinStartupWindow { age_ms: u64 }, +} + /// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 /// /// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 @@ -327,6 +344,58 @@ impl DirectThreadManager { true } + /// 兜底释放一个残留回合:**同一个临界区里**做完"校验 → 解除占用 → 写终态"。 + /// + /// 这三件事必须原子。拆成"先校验、后释放"两次取锁的话,两步之间占用可能已经被真实收口释放, + /// 队列顺势认领了下一条并登记新占用——那时再无条件解除占用,就会清掉**新那一轮**的占用,还给 + /// 已经结束的老回合补一条假终态。这正是"多轮同时在跑 / 终态张冠李戴"那一类事故。 + /// + /// 三条前置条件与 [`DirectStaleTurnRelease`] 的三个失败分支一一对应: + /// 1. 这个 thread 上确实登记着一轮; + /// 2. 传了 `expected_client_turn_id` 时必须与登记一致; + /// 3. `min_age_ms` 有值时,登记的年龄(`now_ms - started_at`)必须不小于它——用来排除 + /// "刚放行、还在本地准备"的正常启动窗口。 + /// + /// 不满足任何一条都**一个字都不写**(占用与事件都保持原样)。通过则终态与占用一起落地:占用 + /// 一解除,队列就可以认领下一条。 + fn release_stale_turn( + &mut self, + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, + now_ms: u64, + ) -> DirectStaleTurnRelease { + let released = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return DirectStaleTurnRelease::NoActiveTurn; + }; + let Some(active) = thread.active_turn.take() else { + return DirectStaleTurnRelease::NoActiveTurn; + }; + if expected_client_turn_id.is_some_and(|expected| expected != active.turn_id.as_str()) { + // 身份对不上:放回去,绝不误伤另一条回合。 + thread.active_turn = Some(active); + return DirectStaleTurnRelease::AnotherTurnRunning; + } + let age_ms = now_ms.saturating_sub(active.started_at); + if min_age_ms.is_some_and(|min_age_ms| age_ms < min_age_ms) { + thread.active_turn = Some(active); + return DirectStaleTurnRelease::WithinStartupWindow { age_ms }; + } + active.turn_id + }; + // 这一轮不会再有人替它发终态(执行进程已退出 / 从没进执行器):补一条 aborted,否则前端的 + // "最新回合是否在跑"会永远停在运行中。身份用被释放那一轮自己的 `turn_id` 推导,与放行时 + // `turn.started` 的口径同一份(`PendingDirectTurn::user_item_id`)。 + self.append( + thread_id, + DirectThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), + ), + ); + DirectStaleTurnRelease::Released(released) + } + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 fn active_turn_identity(&self, thread_id: &str) -> Option { self.threads.get(thread_id).and_then(|thread| { @@ -881,6 +950,31 @@ pub(crate) fn complete_direct_thread_turn_if_reserved( written } +/// 兜底释放一个残留回合:校验与释放**同一个临界区**(见 [`DirectThreadManager::release_stale_turn`])。 +/// +/// 只有真释放了才通知订阅者:校验不过时什么都没写,不该惊动任何人。 +pub(crate) fn release_stale_direct_turn( + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, +) -> DirectStaleTurnRelease { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .release_stale_turn( + thread_id, + expected_client_turn_id, + min_age_ms, + direct_tool_call_now_ms(), + ) + }; + if matches!(outcome, DirectStaleTurnRelease::Released(_)) { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + /// 这个 thread 上正在跑的那一轮身份。 /// /// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 @@ -1596,6 +1690,74 @@ mod tests { assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); } + /// 兜底释放是**一个动作**:判据不过时占用与事件都不动,通过时占用解除与兜底终态一起落地。 + #[test] + fn stale_release_validates_and_releases_as_one_action() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + // 身份对不上:放回去,占用不动、一个字都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", Some("turn-other"), None, FIXED_AT_MS + 10_000), + DirectStaleTurnRelease::AnotherTurnRunning + ); + // 启动窗口内:同样什么都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", None, Some(60_000), FIXED_AT_MS + 1), + DirectStaleTurnRelease::WithinStartupWindow { age_ms: 1 } + ); + assert!(manager.turn_is_active("thread-1")); + assert!( + manager + .consume(&watcher.subscription_id) + .expect("consume rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // 通过:占用解除与兜底终态在同一个临界区里落地,身份取被释放那一轮自己的 turn_id。 + assert_eq!( + manager.release_stale_turn( + "thread-1", + Some("turn-1"), + Some(60_000), + FIXED_AT_MS + 60_000 + ), + DirectStaleTurnRelease::Released("turn-1".to_string()) + ); + assert!(!manager.turn_is_active("thread-1")); + let events = manager + .consume(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + assert!( + matches!( + events[0], + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ), + "{events:?}" + ); + assert_eq!( + events[0].user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + + // 占用已经解除,再释放就是"没有活动回合"。 + assert_eq!( + manager.release_stale_turn("thread-1", None, None, FIXED_AT_MS + 60_000), + DirectStaleTurnRelease::NoActiveTurn + ); + } + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 #[test] fn removal_tells_cancelled_dispatched_and_unknown_apart() { From 9f04940c6d6c39ed0be211b5966819cfd83e7e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 13:04:49 +0800 Subject: [PATCH 42/60] =?UTF-8?q?=E6=95=B4=E5=8C=85=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=81=A2=E5=A4=8D=E5=8F=AA=E5=9C=A8=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=97=B6=E6=89=8D=E5=8F=96=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `recover_direct_taonier_regeneration_workflow_at` 先做只读判据(复用 `direct_taonier_regeneration_workflow_requires_recovery_at`),不需要恢复就直接返回 - 真要动手才取 `direct-codex-art`,并在锁下重读一次判据(以锁下那一份为准) - 原来无条件取锁:正在跑的整包重生成整轮持有它,入队会先同步阻塞约 1 秒再被 `HostStateUnavailable` 拒掉 - 实施计划补一行说明,与 ADR §6"入队不取整轮持有的锁"对齐 - 测试:新增用例"整包重生成在跑(锁被占)时不需要恢复的入队路径不取锁",撤掉修复会红 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 90 +++++++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 2 + 2 files changed, 92 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 64c696479..bc0c140c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1072,8 +1072,19 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_requires_recovery_a pub(in crate::agent) fn recover_direct_taonier_regeneration_workflow_at( root: &Path, ) -> Result { + // 锁只在**真的需要恢复**时才取:正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`,入队 + // 命令若无条件去取它,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒(`..._with_wait` + // 是 `std::thread::sleep`),然后被 `HostStateUnavailable` 拒掉——而队列本该收下它。 + // 判据本身是只读的:没有工作流 / 状态不需要恢复时,这条路一个字都不写。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let _executor_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, "direct-codex-art")?; + // 判据以**锁下那一份**为准:两次读之间真的重生成可能已经推进,或者别的进程已经恢复完了。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let Some(workflow) = read_direct_taonier_regeneration_workflow_at(root)? else { return Ok(false); }; @@ -5632,6 +5643,85 @@ mod tests { } /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 + /// 不需要恢复时**不取**整轮任务锁。 + /// + /// 正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`;入队命令的第一步就是这里,若无条件 + /// 取锁,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒,再被 `HostStateUnavailable` + /// 拒掉——队列本该收下它。判据本身只读,不需要恢复就一个字节都不写、一把锁都不取。 + #[test] + fn recovery_skips_the_executor_task_lock_when_nothing_needs_recovery() { + let root = tempfile::tempdir().expect("recovery lock probe"); + init_local_game_project_at(root.path(), "direct-recovery-lock-probe", "恢复不取锁") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + // 有人正持有这一轮的任务锁:等价于"整包重生成正在跑"。 + let held = + try_acquire_game_creator_agent_runtime_task_lock(root.path(), "direct-codex-art") + .expect("probe the art task lock") + .expect("hold the art task lock"); + + // ① 完全没有工作流:不需要恢复,不去取锁。 + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("an absent workflow needs no recovery") + ); + + // ② 正在正常进行中的整包重生成(InProgress、没有待恢复的替换锚点):这正是"整包重生成在跑" + // 的那一刻——不需要恢复,更**不该去取**那把整轮持有的锁。 + let in_progress = { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root.path(), + "direct-codex.test-in-progress-lock-probe", + ) + .expect("project write lock"); + new_direct_taonier_regeneration_workflow_locked( + root.path(), + &direct_taonier_regeneration_intent_sha256("正在跑的一套美术"), + &direct_taonier_regeneration_invocation_sha256("client-turn-in-progress-probe"), + DirectTaonierRegenerationWorkflowState::InProgress, + ) + .expect("build in-progress workflow") + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &in_progress) + .expect("persist the in-progress workflow"); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("an in-progress workflow is discoverable") + ); + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("a running regeneration needs no recovery"), + "整包重生成在跑时入队不该去取整轮持有的锁" + ); + + // ③ 真需要恢复(Resetting)时仍然要取锁:拿不到就如实报错,绝不当成"恢复过了"。 + let resetting = DirectTaonierRegenerationWorkflow { + state: DirectTaonierRegenerationWorkflowState::Resetting, + ..in_progress + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &resetting) + .expect("persist the resetting workflow"); + assert!( + direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("a resetting workflow is discoverable") + ); + let blocked = recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect_err("a needed recovery must still take the task lock"); + assert!(blocked.contains("direct-codex-art"), "{blocked}"); + + // ④ 锁一放开照旧恢复。 + drop(held); + assert!(recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("resetting recovery resumes once the lock is free")); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read recovered workflow") + .expect("recovered workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + } + #[test] fn stale_turn_release_without_an_active_turn_says_so() { let root = tempfile::tempdir().expect("empty turn root"); diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 5a9d29642..0523b4965 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -67,6 +67,8 @@ **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队; 任何一步失败返回 typed 入队失败。容量预判只是一次提前的快速失败,权威判据仍在入队临界区里(工程准备是分钟级、会 在磁盘留产物的活,满了就不该先做它)。**放行侧**(第 2 步)从占用登记起。 + 其中"工作流恢复"只在**真的需要恢复**时才去取整轮任务锁(`direct-codex-art`):正在跑的那一轮整包重生成整轮持有 + 它,入队无条件取锁会先同步阻塞约 1 秒再被拒(见 ADR §6"入队不取整轮持有的锁")。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From e22154394743f60f5187a2eb82e404cbc5c04e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 13:11:45 +0800 Subject: [PATCH 43/60] =?UTF-8?q?=E7=BB=88=E6=AD=A2=E9=92=AE=E6=8C=89=20ca?= =?UTF-8?q?nStopTurn=20=E6=98=BE=E7=A4=BA=EF=BC=9A=E9=98=9F=E5=88=97?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=E4=B8=8D=E5=86=8D=E9=A1=B6=E5=87=BA=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useDirectProjectTurnStatus` 新增派生判据 `canStopTurn`(`nativeRunning || commandInFlight`)与共享的 `canStopDirectProjectTurn`,写清它与 `displayBusy` 的分工:前者回答"终止这一脚踢给谁",后者回答"界面能不能声称空闲" - composer 的发送钮 / 终止钮按 `canStopTurn` 换;`busy` 继续管模型 / 附件 / 语音这些与当前这轮冲突的控件 - `cancelTurn` 的受理判据改用同一个函数:显示与受理同源,不再出现"按钮在、点了说没有回合" - `displayBusy` 保持三者并集不变(卡片仍要早于首个 token 显示"正在处理") - 测试:`directProjectTurnStatus` 补 `canStopTurn` 用例;appSurface 补"只有待发消息、没有回合在跑时不显示终止钮"(撤掉修复会红) --- .../chat/DirectProjectChatView.tsx | 1 + .../DirectProjectComposer.tsx | 14 ++++++- .../useDirectProjectChatController.ts | 8 +++- .../controller/useDirectProjectTurnStatus.ts | 25 ++++++++++++ .../tests/appSurface/chat-composer.suite.ts | 28 ++++++++++++++ .../tests/directProjectTurnStatus.test.ts | 38 ++++++++++++++++++- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f1c00df6d..94600a01a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -272,6 +272,7 @@ export function DirectProjectChatView({ pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} + canStopTurn={turnStatus.canStopTurn} turnCancelling={turnCancelling} onCancelPendingTurn={(clientTurnId) => void cancelPendingTurn(clientTurnId) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 97e3ae34d..f151e8344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -57,6 +57,7 @@ export function DirectProjectComposer({ pendingTurns, composerNotice, busy, + canStopTurn, turnCancelling, onCancelPendingTurn, onCancelTurn, @@ -72,7 +73,15 @@ export function DirectProjectComposer({ attachmentsImporting: boolean; pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; + /** 「界面忙不忙」:三者并集,用来锁住模型 / 附件 / 语音这些与当前这轮冲突的控件。 */ busy: boolean; + /** + * 「现在点终止有没有作用对象」(见 `useDirectProjectTurnStatus` 的 `canStopTurn`)。 + * + * 与 `busy` 分开:队列非空也算忙,但待发消息不属于「终止」——按 `busy` 显示终止钮会得到一个 + * 点了只会报「当前没有正在运行的回合」的按钮。发送钮 / 终止钮只按它换。 + */ + canStopTurn: boolean; turnCancelling: boolean; onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; @@ -159,7 +168,8 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); - // 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。 + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), + // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( -
-
+
+
{updateGameId ? '发布新版本' : '发布游戏'} -

+

{updateGameId ? updateContext ? `为《${updateContext.title}》发布 v${updateContext.versionNumber + 1}` @@ -724,6 +725,9 @@ export function GamePublishPage({ : '上传可在线游玩的网页游戏'}

+ {!updateGameId ? ( + + ) : null}
{error ? ( From fa56f4567507c5c0c7f7d697c2db754afd0acae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 16:23:34 +0800 Subject: [PATCH 50/60] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E9=A1=B5=E9=9D=A2=E7=9A=84=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=BC=95=E5=AF=BC=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GamePublishClientGuide.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/components/game-distribution/GamePublishClientGuide.tsx diff --git a/src/components/game-distribution/GamePublishClientGuide.tsx b/src/components/game-distribution/GamePublishClientGuide.tsx new file mode 100644 index 000000000..4894d7232 --- /dev/null +++ b/src/components/game-distribution/GamePublishClientGuide.tsx @@ -0,0 +1,35 @@ +import type { HTMLAttributes } from 'react'; + +import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry'; + +type GamePublishClientGuideProps = HTMLAttributes; + +/** 悬浮引导卡片:把还没有游戏包的作者引到客户端。 */ +export function GamePublishClientGuide({ + className, + ...props +}: GamePublishClientGuideProps) { + return ( +
+ + 还没有游戏包? + + +
+ ); +} + +export default GamePublishClientGuide; From 0270cd601e2c818460706369cc42a2cf037c0d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 16:25:05 +0800 Subject: [PATCH 51/60] =?UTF-8?q?DirectProject=20=E5=9F=8B=E7=82=B9?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E6=94=B9=E7=94=B1=E5=AE=BF=E4=B8=BB=E7=BB=93?= =?UTF-8?q?=E7=AE=97=EF=BC=8C=E5=88=A0=E6=8E=89=E5=80=99=E9=80=89/settle?= =?UTF-8?q?=20=E4=B8=A4=E9=98=B6=E6=AE=B5=E4=B8=8E=20analyticsAttemptId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analytics/run.rs:删 Request::DirectCandidate / Request::Settle 与 settle();direct_finished 改收放行代次,终态再读一次 identity_generation,不同则整条不记,相同则直接落 Request::Terminal - analytics/store.rs:删 pending_runs / pending_run_bytes 与两条候选分支(连带 16 条上限),run_request 不再需要字节预算参数 - analytics/gui.rs + main.rs:删 settle_direct_run_analytics 命令与注册 - 放行侧:kick_queue_dispatch 在读 claim 之后读一次平台会话代次并随整轮传下去;user_input 命令不再收 analyticsAttemptId,队列条目不再持有埋点句柄 - 渲染层:删 beginDirectRunAnalytics、句柄表与结算 effect、settle 调用与命令参数;删除 tests/directRunAnalytics.test.ts,appSurface 两处断言同步 - 判据口径随放行搬家:从“发送时与终态同代”改成“放行时与终态同代”,入队后放行前的账号切换不再丢弃成绩 - 验证:cargo test -- analytics::(45 passed,含新增的直写与代际变化两条)、cargo test -- agent::(949 passed)、analytics_real_file_write 用例、npx vitest run tests/appSurface.test.ts(194 passed)、相关 chat/direct 单测 139 passed、typecheck 通过 --- .../src/agent/direct_codex_user_item/mod.rs | 1 - .../src-tauri/src/agent/direct_runtime/mod.rs | 8 +- .../src/agent/direct_runtime/user_input.rs | 29 +-- .../src-tauri/src/agent/direct_tool_bridge.rs | 12 +- .../src/agent/thread_manager/dispatch.rs | 14 +- .../src-tauri/src/agent/thread_manager/mod.rs | 1 - .../src/agent/thread_manager/queue.rs | 5 - .../src-tauri/src/analytics/gui.rs | 5 - .../src-tauri/src/analytics/run.rs | 74 ++----- .../src-tauri/src/analytics/store.rs | 54 +---- .../src-tauri/src/analytics/store_tests.rs | 192 +++++------------- .../src-tauri/src/main.rs | 1 - .../src/services/clientAnalytics.ts | 34 ---- .../useDirectProjectChatController.ts | 84 +------- .../tests/appSurface/chat-composer.suite.ts | 63 +----- .../tests/appSurface/home.suite.ts | 5 - .../tests/directRunAnalytics.test.ts | 91 --------- 17 files changed, 94 insertions(+), 579 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 092a8ae4c..3762251ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,7 +5,6 @@ mod validation; mod wire; pub(crate) use model::DirectCodexUserItem; -pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index b537eeac9..42af654b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -4309,7 +4309,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); @@ -4323,7 +4323,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt turn_emitter, direct_user_item, capture, - analytics_attempt_id, + release_identity_generation, ) .await { @@ -4531,7 +4531,7 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, )>, - analytics_attempt_id: Option<&str>, + release_identity_generation: u64, ) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, @@ -4948,7 +4948,7 @@ async fn run_direct_game_creator_turn_inner( root, &ledger.project_id, metadata, - analytics_attempt_id, + release_identity_generation, crate::analytics::run::Outcome { turn_id: Some(ledger.client_turn_id.clone()), end_reason, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 94dc8e4fc..3cab9e326 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -49,19 +49,12 @@ pub(crate) async fn enqueue_direct_codex_turn( user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, ) -> Result<(), DirectTurnEnqueueFailure> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); - enqueue_direct_codex_turn_typed( - root, - user_item, - creation_type, - client_turn_id, - analytics_attempt_id, - ) - .await - .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) + enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id) + .await + .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } /// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: @@ -76,7 +69,6 @@ async fn enqueue_direct_codex_turn_typed( user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - analytics_attempt_id: Option, ) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { @@ -115,7 +107,6 @@ async fn enqueue_direct_codex_turn_typed( user_item, user_prompt, creation_type, - analytics_attempt_id, direct_tool_call_now_ms(), ) .map_err(|error| DirectTurnError::InputRejected { @@ -186,15 +177,9 @@ mod tests { let subscription = subscribe_thread(&thread_id); let _ = consume_thread(&subscription.subscription_id); - enqueue_direct_codex_turn_typed( - &root, - user_item("你好"), - None, - Some("turn-1".to_string()), - None, - ) - .await - .expect("入队成立:命令只回报入队"); + enqueue_direct_codex_turn_typed(&root, user_item("你好"), None, Some("turn-1".to_string())) + .await + .expect("入队成立:命令只回报入队"); let events = wait_for_completion(&subscription.subscription_id).await; let terminal = events @@ -246,7 +231,6 @@ mod tests { user_item("hello"), None, Some("turn-1".to_string()), - None, ) .await .expect("入队成立:命令只回报入队"); @@ -323,7 +307,6 @@ mod tests { user_item("生成一个游戏"), None, Some("turn-1".to_string()), - None, ) .await .expect("入队成立:命令只回报入队"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 27af35cbd..1300be1b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4202,6 +4202,14 @@ mod tests { let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("project"); let config = temporary.path().join("config"); + // 成绩由宿主在终态直写:这一轮跑在哪个身份下,就看放行代次与终态代次是否同代。 + let _platform_session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (metadata, context, writer) = analytics_test_writer(&config); let original_capture = Some((metadata.context.clone(), writer.clone())); let mut lifecycle = crate::analytics::gui::LifecycleFixture::start( @@ -4264,13 +4272,12 @@ mod tests { ); assert_eq!(session.analytics_output_revision(), Some(revision.clone())); lease.finish(true, true, None).unwrap(); - let attempt = uuid::Uuid::new_v4().to_string(); run::direct_finished( Some((context.clone(), writer.clone())), &root, &project_id, &metadata, - Some(&attempt), + release_identity_generation, run::Outcome { turn_id: Some("analytics-write".into()), end_reason: RunEndReason::Failed, @@ -4280,7 +4287,6 @@ mod tests { revision_id: session.analytics_output_revision(), }, ); - run::settle(Some((context.clone(), writer.clone())), &attempt, false); // 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。 let served_root = crate::project_game_root(&root); fs::create_dir_all(&served_root).unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 9d9f7b7fb..7035391b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -13,12 +13,13 @@ use std::path::{Path, PathBuf}; +#[cfg(test)] +use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, direct_tool_call_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, - PendingTurn, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -72,7 +73,6 @@ impl TurnReservation { .expect("canonical user item"), "测试消息".to_string(), None, - None, direct_tool_call_now_ms(), ) .expect("prepare pending turn"); @@ -106,10 +106,14 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let Some(dispatched) = claim_pending_turn(&thread_id) else { return; }; + // 放行这一刻的身份代次:这一轮的成绩只在"放行时与终态同代"时才入账(见 `analytics::run`)。 + let release_identity_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); tauri::async_runtime::spawn(async move { - run_dispatched_direct_turn(root, dispatched, reservation).await; + run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) + .await; }); } @@ -121,6 +125,7 @@ async fn run_dispatched_direct_turn( root: PathBuf, dispatched: DispatchedTurn, reservation: TurnReservation, + release_identity_generation: u64, ) { let turn_id = dispatched.pending.client_turn_id.clone(); // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 @@ -154,7 +159,7 @@ async fn run_dispatched_direct_turn( Some(&emitter), Some(dispatched.pending.canonical_user_item.clone()), capture, - dispatched.pending.analytics_attempt_id.as_deref(), + release_identity_generation, ) .await; match outcome { @@ -210,7 +215,6 @@ mod tests { .expect("canonical user item"), format!("消息 {client_turn_id}"), None, - None, direct_tool_call_now_ms(), ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 00786e8fe..7e9de8d2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1482,7 +1482,6 @@ mod tests { .expect("canonical user item"), format!("消息 {client_turn_id}"), None, - None, FIXED_AT_MS, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs index b3c37f2b2..6ea36e168 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -35,8 +35,6 @@ pub(crate) struct PendingTurn { /// 入队检查产出的 prompt:放行不重算。 pub(crate) prompt: String, pub(crate) creation_type: Option, - /// 这一轮的运行埋点句柄:由前端在入队时生成,放行开跑时用同一份身份。 - pub(crate) analytics_attempt_id: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, } @@ -51,7 +49,6 @@ impl PendingTurn { user_item: DirectCodexUserItem, prompt: String, creation_type: Option, - analytics_attempt_id: Option, at: u64, ) -> Result { let canonical_user_item = serde_json::to_value(&user_item)?; @@ -61,7 +58,6 @@ impl PendingTurn { canonical_user_item, prompt, creation_type, - analytics_attempt_id, at, }) } @@ -128,7 +124,6 @@ mod tests { user_item("生成一个游戏", "direct-codex:turn-1:user"), "生成一个游戏".to_string(), creation_type.map(str::to_string), - None, 1_700_000_000_123, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs index 71e58c272..df4800ce8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -444,11 +444,6 @@ pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { Some((capture_analytics_context()?, GUI.get()?.writer.clone())) } -#[tauri::command] -pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { - super::run::settle(capture_writer_context(), &attempt_id, discard); -} - fn valid_context(context: &Context) -> bool { GUI.get().is_some_and(|gui| { context.editor_session_id == gui.session_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs index 49673bb20..3222a2422 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs @@ -55,15 +55,6 @@ pub(crate) struct Outcome { #[derive(Serialize)] pub(super) enum Request { - DirectCandidate { - attempt_id: String, - terminal: Box, - }, - Settle { - editor_session_id: String, - attempt_id: String, - discard: bool, - }, Accepted { root: PathBuf, project_id: String, @@ -82,34 +73,11 @@ pub(super) enum Request { impl Request { pub(super) fn session_id(&self) -> &str { match self { - Self::DirectCandidate { terminal, .. } => terminal.session_id(), - Self::Settle { - editor_session_id, .. - } => editor_session_id, Self::Accepted { metadata, .. } => &metadata.context.editor_session_id, Self::Terminal { context, .. } => &context.editor_session_id, } } pub(super) fn validate(&self) -> bool { - match self { - Self::DirectCandidate { - attempt_id, - terminal, - } => { - return uuid::Uuid::parse_str(attempt_id).is_ok() - && matches!(terminal.as_ref(), Self::Terminal { metadata, .. } if metadata.source == Source::Direct) - && terminal.validate(); - } - Self::Settle { - editor_session_id, - attempt_id, - .. - } => { - return uuid::Uuid::parse_str(editor_session_id).is_ok() - && uuid::Uuid::parse_str(attempt_id).is_ok(); - } - _ => {} - } let (root, project_id, metadata) = match self { Self::Accepted { root, @@ -122,7 +90,6 @@ impl Request { metadata, .. } => (root, project_id, metadata), - _ => return false, }; root.is_absolute() && root.as_os_str().len() <= 32768 @@ -166,44 +133,29 @@ pub(crate) fn finished( }); } +/// Direct 回合终态:成绩由宿主自己结算,中间没有候选表,也没有第二次 settle。 +/// +/// `release_identity_generation` 是放行那一刻的身份代次(由放行侧读并随这一轮传下来)。 +/// 终态再读一次,不同就整条不记:换了号 / 退出之后这一轮的成绩不属于任何在册身份。 +/// 判据口径因此从"发送时与终态同代"变成"放行时与终态同代"——入队后、放行前发生的账号切换 +/// 不再丢弃成绩,那一轮确实是在新身份下跑的。 pub(crate) fn direct_finished( capture: Option<(Context, AnalyticsWriter)>, root: &Path, project_id: &str, metadata: &Metadata, - attempt_id: Option<&str>, + release_identity_generation: u64, outcome: Outcome, ) { - let Some(attempt_id) = attempt_id else { return }; - let Some((mut context, writer)) = capture else { - return; - }; if metadata.source != Source::Direct { return; } - context.route = metadata.context.route.clone(); - writer.try_run(Request::DirectCandidate { - attempt_id: attempt_id.into(), - terminal: Box::new(Request::Terminal { - root: root.into(), - project_id: project_id.into(), - metadata: metadata.clone(), - context, - event_time: contract::timestamp_now(), - outcome, - }), - }); -} - -pub(crate) fn settle(capture: Option<(Context, AnalyticsWriter)>, attempt_id: &str, discard: bool) { - let Some((context, writer)) = capture else { + if crate::platform_session::current_platform_session_write_state().identity_generation + != release_identity_generation + { return; - }; - writer.try_run(Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: attempt_id.into(), - discard, - }); + } + finished(capture, root, project_id, metadata, outcome); } #[derive(Serialize, Deserialize)] @@ -266,7 +218,6 @@ pub(super) fn process(request: Request) -> Result, String metadata, .. } => (root, project_id, metadata), - _ => return Ok(None), }; let Some(_lock) = crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")? @@ -387,7 +338,6 @@ pub(super) fn process(request: Request) -> Result, String slot.terminal_consumed = true; Some((context.route.clone(), event)) } - _ => return Ok(None), }; crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs index 3aa951715..445548eaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs @@ -81,7 +81,7 @@ impl AnalyticsWriter { worker_counters .queued_bytes .fetch_sub(bytes, Ordering::Relaxed); - store.run_request(request, bytes); + store.run_request(request); } Ok(Command::Goal(request, bytes)) => { worker_counters @@ -309,8 +309,6 @@ struct Batch { } struct Store { - pending_runs: VecDeque<(String, super::run::Request, u64)>, - pending_run_bytes: u64, root: PathBuf, _owner: File, projects: HashMap, @@ -342,8 +340,6 @@ impl Store { let owner = session::claim(&instance)?; session::recover(&instances, &session_id); let mut store = Self { - pending_runs: VecDeque::new(), - pending_run_bytes: 0, root, _owner: owner, projects: HashMap::new(), @@ -370,57 +366,11 @@ impl Store { Ok(store) } - fn run_request(&mut self, request: super::run::Request, bytes: u64) { - use super::run::Request; + fn run_request(&mut self, request: super::run::Request) { if !request.validate() || request.session_id() != self.session_id { self.counters.dropped.fetch_add(1, Ordering::Relaxed); return; } - let request = match request { - Request::DirectCandidate { - attempt_id, - terminal, - } => { - if bytes > MAX_BYTES as u64 { - return; - } - if self.pending_runs.iter().any(|(id, _, _)| id == &attempt_id) { - return; - } - while self.pending_runs.len() >= 16 - || self.pending_run_bytes + bytes > MAX_BYTES as u64 - { - let Some((_, _, removed)) = self.pending_runs.pop_front() else { - break; - }; - self.pending_run_bytes -= removed; - self.counters.dropped.fetch_add(1, Ordering::Relaxed); - } - self.pending_run_bytes += bytes; - self.pending_runs.push_back((attempt_id, *terminal, bytes)); - return; - } - Request::Settle { - attempt_id, - discard, - .. - } => { - let Some(index) = self - .pending_runs - .iter() - .position(|(id, _, _)| id == &attempt_id) - else { - return; - }; - let (_, terminal, removed) = self.pending_runs.remove(index).unwrap(); - self.pending_run_bytes -= removed; - if discard { - return; - } - terminal - } - other => other, - }; match super::run::process(request) { Ok(Some((route, event))) => { let fact = format!("{}:terminal", event.agent_run_id.as_deref().unwrap()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs index 219949cc9..2e0af4155 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs @@ -1079,167 +1079,75 @@ fn missing_overwritten_or_unwritable_run_slots_do_not_fabricate_terminal_events( fs::remove_dir(backup).unwrap(); } +/// Direct 成绩由宿主直写终态:放行代次与终态代次相同就落一条成绩事件,中间没有候选表。 #[test] -fn direct_terminal_waits_for_exact_attempt_settlement_and_discards_intermediate_failures() { - use super::super::{ - contract::{ErrorCode, RunEndReason, RunSource, Source}, - run, - }; - let (_project_dir, root) = goal_project(); - let config = tempfile::tempdir().unwrap(); - let (context, writer) = goal_writer(config.path(), "A"); - let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - run::accepted(&writer, &root, "goal-project", &metadata); - let first = uuid::Uuid::new_v4().to_string(); - let final_attempt = uuid::Uuid::new_v4().to_string(); - let capture = || Some((context.clone(), writer.clone())); - let mut failure = run_outcome(); - failure.end_reason = RunEndReason::Failed; - failure.error_code = Some(ErrorCode::ProviderTimeout); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&first), - failure.clone(), - ); - run::settle(capture(), &uuid::Uuid::new_v4().to_string(), false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - // 另一个被取消的尝试不消费槽位;保留第一次失败,验证最终确认不会选错它。 - let cancelled = uuid::Uuid::new_v4().to_string(); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&cancelled), - failure, - ); - run::settle(capture(), &cancelled, true); - run::settle(capture(), &cancelled, false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - None, - run_outcome(), - ); - run::direct_finished( - capture(), - &root, - "goal-project", - &metadata, - Some(&final_attempt), - run_outcome(), - ); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - run::settle(capture(), &final_attempt, false); - run::settle(capture(), &final_attempt, false); - let events = drain_goal_writer(config.path(), &context, &writer); - let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); - assert_eq!(terminal.len(), 1); - assert_eq!(terminal[0].event_name, "agent_run_completed"); - assert_eq!(terminal[0].event_id, metadata.terminal_event_id); -} - -#[test] -fn direct_pending_capacity_evicts_oldest_without_settlement_fallback() { +fn direct_terminal_writes_once_when_the_identity_generation_is_unchanged() { use super::super::{ contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); run::accepted(&writer, &root, "goal-project", &metadata); - let ids: Vec<_> = (0..17).map(|_| uuid::Uuid::new_v4().to_string()).collect(); - for id in &ids { - run::direct_finished( - Some((context.clone(), writer.clone())), - &root, - "goal-project", - &metadata, - Some(id), - run_outcome(), - ); - } - run::settle(Some((context.clone(), writer.clone())), &ids[0], false); - assert!(!drain_goal_writer(config.path(), &context, &writer) - .iter() - .any(|e| e.agent_run_id.is_some())); - assert!(writer.counters.dropped.load(Ordering::Relaxed) >= 1); - run::settle(Some((context.clone(), writer.clone())), &ids[16], false); - assert_eq!( - drain_goal_writer(config.path(), &context, &writer) - .iter() - .filter(|e| e.agent_run_id.is_some()) - .count(), - 1 + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); + let events = drain_goal_writer(config.path(), &context, &writer); + let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(terminal.len(), 1, "一轮只落一条成绩:{events:?}"); + assert_eq!(terminal[0].event_name, "agent_run_completed"); + assert_eq!(terminal[0].event_id, metadata.terminal_event_id); } +/// 放行之后换号 / 退出:终态代次不再相同,这一轮的成绩整条不记。 #[test] -fn direct_pending_byte_budget_and_session_are_checked_before_consumption() { +fn direct_terminal_is_dropped_when_the_identity_generation_changed_since_release() { use super::super::{ - contract::{Context, RunSource, Source}, + contract::{RunSource, Source}, run, }; + let _session = crate::platform_session::install_test_platform_session( + "A", + "token-a", + "https://dev.genarrative.world", + ); + let release_generation = + crate::platform_session::current_platform_session_write_state().identity_generation; + let (_project_dir, root) = goal_project(); let config = tempfile::tempdir().unwrap(); - let mut store = open(config.path()); - let context = Context { - route: route("A"), - editor_session_id: store.session_id.clone(), - client_version: "1.0.0".into(), - }; + let (context, writer) = goal_writer(config.path(), "A"); let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); - let mut last = String::new(); - for _ in 0..10 { - last = uuid::Uuid::new_v4().to_string(); - // worker接收到的序列化预算是独立的保守保留量。 - store.run_request( - run::Request::DirectCandidate { - attempt_id: last.clone(), - terminal: Box::new(run::Request::Terminal { - root: config.path().into(), - project_id: "project".into(), - metadata: metadata.clone(), - context: context.clone(), - event_time: "2026-09-21T00:00:00.000Z".into(), - outcome: run_outcome(), - }), - }, - 128 * 1024, - ); - } - assert_eq!(store.pending_runs.len(), 8); - assert_eq!(store.pending_run_bytes, MAX_BYTES as u64); - store.run_request( - run::Request::Settle { - editor_session_id: uuid::Uuid::new_v4().to_string(), - attempt_id: last.clone(), - discard: true, - }, - 128, + run::accepted(&writer, &root, "goal-project", &metadata); + // 这一轮还在跑的时候身份代次推进了(换号 / 退出)。 + let next = + crate::platform_session::current_platform_session_write_state().identity_generation + 1; + crate::platform_session::clear_platform_session(next, next); + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + release_generation, + run_outcome(), ); - assert_eq!(store.pending_runs.len(), 8); - store.run_request( - run::Request::Settle { - editor_session_id: context.editor_session_id, - attempt_id: last, - discard: true, - }, - 128, + assert!( + drain_goal_writer(config.path(), &context, &writer) + .iter() + .all(|event| event.agent_run_id.is_none()), + "终态代次不同时不能落任何成绩" ); - assert_eq!(store.pending_runs.len(), 7); - assert_eq!(store.pending_run_bytes, 7 * 128 * 1024); } 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 00031961f..52a019481 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2590,7 +2590,6 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ analytics::gui::capture_analytics_context, - analytics::gui::settle_direct_run_analytics, analytics::gui::record_analytics_project_open, analytics::gui::record_analytics_project_leave, analytics::gui::record_analytics_ui_save, diff --git a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts index 7e5c928c6..453c7ce85 100644 --- a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts +++ b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts @@ -1,39 +1,5 @@ import type { TauriInvoke } from '../app/types'; -// 一个 run 可因自动认证刷新调用多次 native,只确认最后一次尝试。 -export function beginDirectRunAnalytics( - invoke: TauriInvoke, - currentGeneration: () => number, -) { - const generation = currentGeneration(); - let attemptId: string | undefined; - let settled = false; - return { - nextAttempt() { - attemptId = undefined; - try { - attemptId = crypto.randomUUID(); - } catch { - // 最后一次 UUID 失败不能留下前一次失败候选的标识。 - } - return attemptId; - }, - settle() { - if (settled) return; - settled = true; - if (!attemptId) return; - try { - void invoke('settle_direct_run_analytics', { - attemptId, - discard: currentGeneration() !== generation, - }).catch(() => undefined); - } catch { - // 不等待埋点,不改变调用链的成功、失败和取消结果。 - } - }, - }; -} - // 宿主签发的无凭据上下文:前端只传回原值,不从当前登录态补身份。 type AnalyticsContext = { route: { user_id: string | null; destination_origin: string | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index c9c607f97..8f97e9b3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -16,9 +16,7 @@ import { directCodexUserItemFromContent, hasMeaningfulDirectCodexContent, } from '../../../../features/project-workspace/resourceReferences'; -import { beginDirectRunAnalytics } from '../../../../services/clientAnalytics'; import { captureAgentRuntimeError } from '../../../../services/errorReporting'; -import { currentPlatformSessionGeneration } from '../../../../services/platformSession'; import { createDirectProjectTurnId, DIRECT_CODEX_AGENT_ID, @@ -179,11 +177,6 @@ export function useDirectProjectChatController({ const directEntries = directThread.entries; const currentTurnRunning = directThread.turnRunning; const currentTurnStartedAt = directThread.turnStartedAt; - const completedTurnCount = directThread.completedTurnCount; - /** - * 本轮开口条目的 canonical 身份:收口之后仍留着,用来把"刚结束的是哪一轮"讲清楚(结算埋点读它)。 - */ - const currentTurnUserItemId = directThread.state.turnUserItemId; /** * 待发消息:**投影自事件流**,界面不持有第二份队列。入队、取消、放行都只让这条列表跟着变。 */ @@ -197,19 +190,6 @@ export function useDirectProjectChatController({ directTurnRunningRef.current = currentTurnRunning; const commandInFlightRef = useRef(commandInFlight); commandInFlightRef.current = commandInFlight; - /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ - const handledCompletedTurnCountRef = useRef(0); - /** - * 每个已经交给宿主的回合的埋点句柄,按 `clientTurnId` 存。 - * - * 必须按身份存:队列在宿主侧,同一时刻可能有好几条待发消息(各自带着自己的 attempt id), - * 而成绩是**各自那一轮**收尾时在宿主侧入账的——单个槽位只会结算到最后一个,前面的全部静默丢掉。 - * 句柄活到本轮终态(见下面结算的那个 effect)。 - */ - const pendingRunAnalyticsRef = useRef( - new Map>(), - ); - useEffect(() => { setAttachmentNotice(''); setComposerNotice(''); @@ -217,8 +197,6 @@ export function useDirectProjectChatController({ setLocalMessages([]); setHistoryHasMore(false); historyOldestItemIdRef.current = null; - handledCompletedTurnCountRef.current = 0; - pendingRunAnalyticsRef.current.clear(); }, [projectPath]); // 回合期间的平台会话保活由 Rust 持有:Direct 回合的占用登记时启动,占用释放(回合收口)即停止; @@ -227,26 +205,6 @@ export function useDirectProjectChatController({ // 本地忙态不再由这里兜:入队化之后"这一轮开没开"只由事件流回答(`useDirectProjectTurnStatus` 的 // `commandInFlight` / `pendingCount` / `nativeRunning` 三者并集)。 - /** - * 回合终态是**埋点结算**的唯一出口(入队失败走命令那条路,见 `runTurn` 的清理)。 - * - * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 - * 开始并结束,那时下降沿永远不会出现,这一轮的埋点就永远没人结算。 - * - * 队列**不在这里推进**:待发消息归宿主,下一条什么时候走由 Thread Manager 在占用释放时自己踢。 - */ - useEffect(() => { - if (!enabled) { - handledCompletedTurnCountRef.current = completedTurnCount; - return; - } - if (completedTurnCount === handledCompletedTurnCountRef.current) return; - handledCompletedTurnCountRef.current = completedTurnCount; - // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 - settlePendingRunAnalytics(currentTurnUserItemId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, completedTurnCount, currentTurnUserItemId]); - useEffect(() => { if (!enabled || !projectPath) return; let disposed = false; @@ -283,29 +241,6 @@ export function useDirectProjectChatController({ setCommandInFlight(false); } - /** - * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 - * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 - * - * 认领靠身份:`userItemId` 是本轮开口条目的 canonical id,与 `clientTurnId` 一一对应 - * (`directCodexConversationMessageId`)。身份缺失(旧事件 / 没有开口条目)时退一步结算**最早**的 - * 那一条——放行严格按队首顺序,收口顺序就是入队顺序,这一层没有歧义。 - */ - function settlePendingRunAnalytics(turnUserItemId: string) { - const pending = pendingRunAnalyticsRef.current; - if (pending.size === 0) return; - const matched = [...pending.keys()].find( - (clientTurnId) => - directCodexConversationMessageId(clientTurnId, 'user') === - turnUserItemId, - ); - const key = matched ?? pending.keys().next().value; - if (key === undefined) return; - const handle = pending.get(key); - pending.delete(key); - handle?.settle(); - } - function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } @@ -330,10 +265,7 @@ export function useDirectProjectChatController({ ); if (projectPathRef.current !== nextProjectPath) return; if (outcome === 'removed') { - // 这一条不会再被放行,也就不会再有 `turn.completed` 来结算它:句柄只能在这里清掉。 - // 留着不但泄漏,还会挤进"身份缺失"的兜底结算的候选里(`settlePendingRunAnalytics` - // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 - pendingRunAnalyticsRef.current.delete(clientTurnId); + // 这一条不会再被放行:界面上撤掉它就行,成绩本来也只在放行后由宿主自己结算。 } if (outcome === 'notFound') { setComposerNotice('这条待发消息已经不在队列里'); @@ -544,18 +476,10 @@ export function useDirectProjectChatController({ // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 let draftSafe = false; try { - const runAnalytics = beginDirectRunAnalytics( - invoke, - currentPlatformSessionGeneration, - ); - // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 - // 按 `clientTurnId` 存:队列在宿主侧,同一时刻可能有好几条待发消息各自带着自己的身份。 - pendingRunAnalyticsRef.current.set(input.clientTurnId, runAnalytics); await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, userItem: input.userItem, - analyticsAttemptId: runAnalytics.nextAttempt(), ...(input.creationType ? { creationType: input.creationType } : {}), }); draftSafe = true; @@ -564,12 +488,6 @@ export function useDirectProjectChatController({ // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 // 只剩"入队失败"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 const rejection = readDirectTurnEnqueueFailure(error); - // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), - // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 - // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 - if (rejection) { - pendingRunAnalyticsRef.current.delete(input.clientTurnId); - } if (rejection) { const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 8731915e6..a430609b4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -495,7 +495,7 @@ export function registerChatComposerControlTests() { it('does not re-run the whole DirectProject turn when authentication fails', async () => { // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, - // 整轮只 invoke 一次、埋点只结算一次。 + // 整轮只 invoke 一次。 const { invoke, surface } = await openDirectCodexSurface({ enqueue_direct_codex_turn: () => { throw new Error('authentication-required'); @@ -520,12 +520,6 @@ export function registerChatComposerControlTests() { ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); - // 这一轮没有入队,不会有回合终态事件来驱动结算:埋点一次也不结算。 - expect( - invoke.mock.calls.filter( - ([command]) => command === 'settle_direct_run_analytics', - ), - ).toHaveLength(0); // 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( @@ -647,61 +641,6 @@ export function registerChatComposerControlTests() { ).toEqual(['第一条消息', '第二条消息', '第三条消息']); }); - it('不再认领被取消那一条的埋点句柄:身份缺失的兜底结算只落到还能收口的那一条', async () => { - // 取消掉的待发消息永远不会被放行,也就永远不会有它自己的 `turn.completed`:句柄必须在取消时清掉。 - // 留着的话,"身份缺失"的兜底结算(认领表里**最早**那一条)会先认出它——取消一条消息等于把下一次 - // 结算算到一条根本没跑过的回合头上,真正跑着的那条反而没人结算。 - let host: ReturnType | null = null; - const { invoke, surface, harness } = await openDirectCodexSurface({ - enqueue_direct_codex_turn: (args) => host?.enqueue(args), - remove_direct_project_pending_turn: (args) => host?.cancel(args), - }); - // 线程上已有一条(恢复出来的)回合在跑:发出去的两条都只会排队,事件流里也不会走 `turn.started`。 - host = hostQueue(harness, { running: true }); - const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await setComposerText(composer, '会被取消的消息'); - submitComposerForm(composer); - await setComposerText(composer, '留下来的消息'); - submitComposerForm(composer); - const queue = await within(surface).findByLabelText('待发送消息队列'); - await waitFor(() => { - expect(within(queue).getAllByRole('listitem')).toHaveLength(2); - }); - - fireEvent.click( - within(queue).getByRole('button', { - name: '取消待发消息 会被取消的消息', - }), - ); - await waitFor(() => { - expect(within(queue).getAllByRole('listitem')).toHaveLength(1); - }); - const attempts = invoke.mock.calls - .filter(([command]) => command === 'enqueue_direct_codex_turn') - .map(([, args]) => String(args?.analyticsAttemptId ?? '')); - expect(attempts).toHaveLength(2); - const settledAttemptIds = () => - invoke.mock.calls - .filter(([command]) => command === 'settle_direct_run_analytics') - .map(([, args]) => String(args?.attemptId ?? '')); - - // 回放一条**没有身份**的失败终态:订阅重建时的生命周期锚点就是这个形状(订阅在新回合开始前 - // 断过,开局那条 `turn.started` 不在手里)。兜底结算此刻只能按"认领表里最早那一条"认人—— - // 被取消的那条若还留在表里,就会顶替掉真正该结算的那一条。 - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'failed', - failure: { message: '重连之前那一轮已经失败' }, - at: 4, - }); - }); - await waitFor(() => { - expect(settledAttemptIds()).toHaveLength(1); - }); - expect(settledAttemptIds()).toEqual([attempts[1]]); - }); - it('tells the user when the chip they tried to cancel has already been dispatched', async () => { // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 const { invoke, surface, harness } = await openDirectCodexSurface({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index c8a3c1312..fd9364151 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1537,7 +1537,6 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -1657,7 +1656,6 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -1696,7 +1694,6 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath: automaticProjectPath, clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -2447,7 +2444,6 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath, clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -2501,7 +2497,6 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath, clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), userItem: { id: `direct-codex:${clientTurnId}:user`, type: 'message', diff --git a/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts b/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts deleted file mode 100644 index 245e2ebe8..000000000 --- a/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, expect, it, vi } from 'vitest'; - -import type { TauriInvoke } from '../src/app/types'; -import { beginDirectRunAnalytics } from '../src/services/clientAnalytics'; - -afterEach(() => vi.restoreAllMocks()); - -it('自动重试保持业务回合,只确认最后一次尝试且确认不等待桥接', async () => { - const first = '11111111-1111-4111-8111-111111111111'; - const last = '22222222-2222-4222-8222-222222222222'; - vi.spyOn(crypto, 'randomUUID') - .mockReturnValueOnce(first) - .mockReturnValueOnce(last); - const invoke = vi.fn(() => new Promise(() => {})); - const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); - const attempts: string[] = []; - const operation = async () => { - attempts.push(analytics.nextAttempt()!); - if (attempts.length === 1) throw new Error('authentication-required'); - return '完成'; - }; - let result: string; - try { - result = await operation().catch(() => operation()); - expect(invoke).not.toHaveBeenCalled(); - } finally { - analytics.settle(); - } - expect(result).toBe('完成'); - expect(attempts).toEqual([first, last]); - expect(invoke).toHaveBeenCalledTimes(1); - expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { - attemptId: last, - discard: false, - }); - analytics.settle(); - expect(invoke).toHaveBeenCalledTimes(1); -}); - -it('最终 UUID 失败不能回退确认第一次失败', () => { - vi.spyOn(crypto, 'randomUUID') - .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') - .mockImplementationOnce(() => { - throw new Error('UUID unavailable'); - }); - const invoke = vi.fn(); - const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); - expect(analytics.nextAttempt()).toBeDefined(); - expect(analytics.nextAttempt()).toBeUndefined(); - analytics.settle(); - expect(invoke).not.toHaveBeenCalled(); -}); - -it('账号代次变化只丢弃候选,不提交成功失败结论', () => { - let generation = 1; - const invoke = vi.fn(async () => undefined); - const analytics = beginDirectRunAnalytics( - invoke as TauriInvoke, - () => generation, - ); - const attemptId = analytics.nextAttempt(); - generation = 2; - analytics.settle(); - expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { - attemptId, - discard: true, - }); -}); - -it.each(['sync', 'async'])( - '确认桥接 %s 失败不会覆盖最终业务错误', - async (mode) => { - const invoke = vi.fn(() => { - if (mode === 'sync') throw new Error('bridge'); - return Promise.reject(new Error('bridge')); - }); - const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); - const failure = new Error('最终执行失败'); - const operation = async () => { - try { - analytics.nextAttempt(); - throw failure; - } finally { - analytics.settle(); - } - }; - await expect(operation()).rejects.toBe(failure); - expect(invoke).toHaveBeenCalledTimes(1); - }, -); From 28b64cf25a1e35f06472afdf087df65c2163fa26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 16:37:06 +0800 Subject: [PATCH 52/60] =?UTF-8?q?DirectProject=20=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=9D=A1=E7=9B=AE=E6=94=B9=E4=B8=BA=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=8A=95=E5=BD=B1=EF=BC=8C=E6=94=BE=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E6=8A=95=E5=BD=B1=20canonical=20=E4=B8=8E=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - queue.rs:PendingTurn 退化成 queue.enqueued 的投影(new / from_event / enqueued_event 往返),删掉 canonical_user_item 与 prompt 两个宿主私有产物字段 - thread_manager/mod.rs:删 StoredEvent.pending(append_inner 并回 append);pending_turns 改为一次事件折叠(有 queue.enqueued、没有配对 queue.removed),容量、判重、remove、claim 全读这一次折叠;mark_queue_events_cleanable 删掉“产物还在就不回收”的防御 - dispatch.rs:放行的 canonical = serde_json::to_value(&pending.user_item),prompt = direct_codex_user_item_to_prompt(&pending.user_item)——不写盘、不读 manifest、不重跑校验 - user_input.rs:入队只造条目本身,不再冻结 prompt / canonical 形状 - wire.rs:queue.enqueued 注释改为“这条事件就是这条待发消息的全部事实” - 验证:cargo test -- thread_manager::(62 passed,单线程跑进程级计数器用例)、-- agent::(948 passed,另一条 design_runtime 用例为既有并发 flaky,单跑通过)、-- direct_runtime:: analytics:: direct_codex_user_item(182 passed)、npx vitest run appSurface/directThreadChat/directHistoryPaging/directProjectTurn(249 passed) --- .../src/agent/direct_runtime/user_input.rs | 15 +-- .../src/agent/thread_manager/dispatch.rs | 31 ++--- .../src-tauri/src/agent/thread_manager/mod.rs | 119 ++++++++---------- .../src/agent/thread_manager/queue.rs | 85 +++++++------ .../src/agent/thread_manager/wire.rs | 3 +- 5 files changed, 115 insertions(+), 138 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 3cab9e326..7b7cc68d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -100,18 +100,9 @@ async fn enqueue_direct_codex_turn_typed( let detail = redact_agent_runtime_error(root, &error, 1800); DirectTurnError::EnvironmentNotReady { detail } })?; - // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。prompt 与 canonical 形状在这里 - // 冻结——放行不重算,所以放行没有失败出口。 - let pending = PendingTurn::prepare( - turn_id, - user_item, - user_prompt, - creation_type, - direct_tool_call_now_ms(), - ) - .map_err(|error| DirectTurnError::InputRejected { - detail: error.to_string(), - })?; + // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 + // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 + let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms()); match enqueue_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 7035391b1..22e016807 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_tool_call_now_ms, redact_agent_runtime_error, + direct_codex_user_item_to_prompt, direct_tool_call_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, }; @@ -62,7 +62,7 @@ impl TurnReservation { /// 测试用:直接开一轮占用,等价于"入队 + 认领",不经过 Tauri 命令与真实的用户条目。 #[cfg(test)] pub(crate) fn accept_for_test(thread_id: &str, client_turn_id: &str) -> Self { - let pending = PendingTurn::prepare( + let pending = PendingTurn::new( client_turn_id.to_string(), serde_json::from_value(serde_json::json!({ "type": "message", @@ -71,11 +71,9 @@ impl TurnReservation { "id": format!("direct-codex:{client_turn_id}:user"), })) .expect("canonical user item"), - "测试消息".to_string(), None, direct_tool_call_now_ms(), - ) - .expect("prepare pending turn"); + ); super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); Self::resume(thread_id, &dispatched) @@ -128,10 +126,12 @@ async fn run_dispatched_direct_turn( release_identity_generation: u64, ) { let turn_id = dispatched.pending.client_turn_id.clone(); + // 放行只搬运条目自己的事实:canonical 形状与 prompt 都从冻结过的条目重投影,不重算、不写盘。 + let canonical_user_item = serde_json::to_value(&dispatched.pending.user_item) + .expect("冻结过的 canonical 条目一定可序列化"); + let prompt = direct_codex_user_item_to_prompt(&dispatched.pending.user_item); // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 - if let Err(error) = - append_direct_project_user_message_at(&root, &dispatched.pending.canonical_user_item) - { + if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 // 消息的助手回复,而且失败会被静默掉。 let failure = DirectTurnError::EnvironmentNotReady { @@ -146,18 +146,15 @@ async fn run_dispatched_direct_turn( } // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 // 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。 - crate::agent::codex_app_server::emit_direct_thread_user_item( - &root, - &dispatched.pending.canonical_user_item, - ); + crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( &root, - &dispatched.pending.prompt, + &prompt, dispatched.pending.creation_type.as_deref(), Some(&emitter), - Some(dispatched.pending.canonical_user_item.clone()), + Some(canonical_user_item.clone()), capture, release_identity_generation, ) @@ -202,9 +199,9 @@ mod tests { .collect() } - /// 一条待发消息。放行路径只读它的身份与产物,所以这里用最小可用形状。 + /// 一条待发消息。放行路径只读它自己的事实(身份、条目、创建类型),所以这里用最小可用形状。 fn pending_turn(client_turn_id: &str) -> PendingTurn { - PendingTurn::prepare( + PendingTurn::new( client_turn_id.to_string(), serde_json::from_value(serde_json::json!({ "type": "message", @@ -213,11 +210,9 @@ mod tests { "id": format!("direct-codex:{client_turn_id}:user"), })) .expect("canonical user item"), - format!("消息 {client_turn_id}"), None, direct_tool_call_now_ms(), ) - .expect("prepare pending turn") } /// 真实存在的项目目录不是这条用例的判据,用唯一的假路径当线程身份即可:认领与占用的语义只认 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 7e9de8d2f..0055d42e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -38,12 +38,6 @@ struct StoredEvent { seq: u64, bytes: usize, cleanable: bool, - /// 这条事件在队期间挂着的宿主侧产物。 - /// - /// 只有 `queue.enqueued` 会带它,而且**它本身就是队列成员身份**:`Some` = 这条待发消息还在队, - /// 被取走 = 已经离开队列。队列的先后就是事件的先后,所以没有第二张队列表要对齐,也不会出现 - /// "事件说在队、队列表说不在"的中间态(见 `thread_manager::queue`)。 - pending: Option, } #[derive(Clone, Debug)] @@ -194,16 +188,6 @@ impl ThreadManager { } pub(crate) fn append(&mut self, thread_id: &str, event: ThreadEvent) -> ThreadEvent { - self.append_inner(thread_id, event, None) - } - - /// 追加事件。`pending` 用来把宿主侧产物挂到这条事件上(只有入队会传)。 - fn append_inner( - &mut self, - thread_id: &str, - event: ThreadEvent, - pending: Option, - ) -> ThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); let seq = thread.next_seq; @@ -217,7 +201,6 @@ impl ThreadManager { seq, bytes, cleanable, - pending, }); if let Some(item_id) = event.item_id() { Self::mark_item_events_cleanable(thread, item_id, seq); @@ -418,13 +401,26 @@ impl ThreadManager { } /// 在队的待发消息,队首在前。 - fn pending_turns(thread: &ThreadState) -> Vec<&PendingTurn> { - thread - .events - .iter() - .skip(thread.head) - .filter_map(|stored| stored.pending.as_ref()) - .collect() + /// + /// 队列就是事件列表:一条待发消息在队 = 事件窗口里"有 `queue.enqueued`、没有配对 + /// `queue.removed`"。一次折叠既判成员也定顺序,不需要第二张表;在队条目的入队事件永不可回收, + /// 所以它不会被前缀回收吃掉(见 `thread_manager::queue` 与 [`Self::observe_event`])。 + fn pending_turns(thread: &ThreadState) -> Vec { + let mut pending: Vec = Vec::new(); + for stored in thread.events.iter().skip(thread.head) { + match &stored.event { + ThreadEvent::QueueEnqueued { .. } => { + if let Some(turn) = PendingTurn::from_event(&stored.event) { + pending.push(turn); + } + } + ThreadEvent::QueueRemoved { client_turn_id, .. } => { + pending.retain(|turn| &turn.client_turn_id != client_turn_id); + } + _ => {} + } + } + pending } /// 入队:把一条已经通过入队检查的待发消息排到队尾。 @@ -436,6 +432,7 @@ impl ThreadManager { thread_id: &str, turn: PendingTurn, ) -> Result { + let event = turn.enqueued_event(); { let thread = self.threads.entry(thread_id.to_string()).or_default(); let already_known = Self::pending_turns(thread) @@ -450,8 +447,7 @@ impl ThreadManager { } queue_has_room(Self::pending_turns(thread).len())?; } - let event = turn.enqueued_event(); - self.append_inner(thread_id, event, Some(turn)); + self.append(thread_id, event); Ok(EnqueueOutcome::Enqueued) } @@ -464,35 +460,24 @@ impl ThreadManager { client_turn_id: &str, at_ms: u64, ) -> QueueRemovalOutcome { - let removed = { - let Some(thread) = self.threads.get_mut(thread_id) else { - return QueueRemovalOutcome::NotFound; - }; - if thread - .active_turn - .as_ref() - .is_some_and(|active| active.turn_id == client_turn_id) - { - return QueueRemovalOutcome::AlreadyDispatched; - } - let head = thread.head; - let mut removed = false; - for stored in thread.events.iter_mut().skip(head) { - if stored - .pending - .as_ref() - .is_some_and(|pending| pending.client_turn_id == client_turn_id) - { - // 取走产物即离开队列:这一幕在临界区里发生,外面看不到"事件还在、条目已经不在"。 - stored.pending = None; - removed = true; - } - } - removed + let Some(thread) = self.threads.get(thread_id) else { + return QueueRemovalOutcome::NotFound; }; - if !removed { + if thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == client_turn_id) + { + return QueueRemovalOutcome::AlreadyDispatched; + } + if !Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == client_turn_id) + { return QueueRemovalOutcome::NotFound; } + // 离开队列这件事由 `queue.removed` 自己表达:追加即出队(同一条入队事件就此转成可回收), + // 所以入队成功之后这里一定找得到那一条。 self.append( thread_id, ThreadEvent::queue_removed( @@ -515,17 +500,11 @@ impl ThreadManager { started_at_ms: u64, ) -> Option { let pending = { - let thread = self.threads.get_mut(thread_id)?; + let thread = self.threads.get(thread_id)?; if thread.active_turn.is_some() { return None; } - let head = thread.head; - let stored = thread - .events - .iter_mut() - .skip(head) - .find(|stored| stored.pending.is_some())?; - stored.pending.take().expect("filtered on a pending turn") + Self::pending_turns(thread).into_iter().next()? }; let token = Uuid::new_v4().to_string(); let turn_id = pending.client_turn_id.clone(); @@ -745,10 +724,8 @@ impl ThreadManager { /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { for stored in &mut thread.events { - // 仍挂着产物的条目永远不算可回收:在队条目被回收就等于队列悄悄丢了一条消息。 - if stored.pending.is_none() - && stored.event.queue_client_turn_id() == Some(client_turn_id) - { + // 配对的两条一起转可回收——在队条目的入队事件本来就不会走到这里。 + if stored.event.queue_client_turn_id() == Some(client_turn_id) { stored.cleanable = true; } } @@ -1064,7 +1041,10 @@ pub(crate) fn unsubscribe_thread(subscription_id: &str) { #[cfg(test)] mod tests { use super::*; - use crate::agent::{ThreadDeltaKind, ThreadItem, ThreadRequestKind, MAX_PENDING_TURNS}; + use crate::agent::{ + direct_codex_user_item_to_prompt, ThreadDeltaKind, ThreadItem, ThreadRequestKind, + MAX_PENDING_TURNS, + }; fn message(item_id: &str) -> ThreadItem { ThreadItem::Message { @@ -1471,7 +1451,7 @@ mod tests { } fn pending_turn(client_turn_id: &str) -> PendingTurn { - PendingTurn::prepare( + PendingTurn::new( client_turn_id.to_string(), serde_json::from_value(serde_json::json!({ "type": "message", @@ -1480,11 +1460,9 @@ mod tests { "id": format!("direct-codex:{client_turn_id}:user"), })) .expect("canonical user item"), - format!("消息 {client_turn_id}"), None, FIXED_AT_MS, ) - .expect("prepare pending turn") } fn enqueued_ids(events: &[ThreadEvent]) -> Vec<&str> { @@ -1680,7 +1658,10 @@ mod tests { .claim_pending_turn("thread-1", 2_000) .expect("claim head"); assert_eq!(claimed.pending.client_turn_id, "turn-1"); - assert_eq!(claimed.pending.prompt, "消息 turn-1"); + assert_eq!( + direct_codex_user_item_to_prompt(&claimed.pending.user_item), + "消息 turn-1" + ); assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); let events = manager diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs index 6ea36e168..12fc6879c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -4,11 +4,10 @@ //! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此 //! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。 //! -//! 这个模块只放三件事:一条待发消息带走什么([`PendingTurn`])、容量规则 -//! ([`MAX_PENDING_TURNS`])、以及它在线上长什么样([`PendingTurn::enqueued_event`])。 -//! 它不碰锁、不碰 Tauri、不写盘:入队检查在命令侧,放行顺序在 Thread Manager。 - -use serde_json::Value; +//! 这个模块只放三件事:一条待发消息在线上长什么样([`PendingTurn`] 与 +//! [`PendingTurn::enqueued_event`])、容量规则([`MAX_PENDING_TURNS`])、以及"它还在不在队" +//! 这条判据的成员折叠(`ThreadManager::pending_turns`)。 +//! 它不碰锁、不碰 Tauri、不写盘、不重算 prompt:入队检查在命令侧,放行顺序在 Thread Manager。 use crate::agent::{ direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, ThreadEvent, @@ -22,48 +21,57 @@ pub(crate) const MAX_PENDING_TURNS: usize = 5; /// 一条已经通过入队检查、正在等放行的用户消息。 /// -/// 只在内存里,进程重启即消失(与 ADR 记的边界一致)。它同时是**放行时要用的全部输入**:放行 -/// 没有失败出口,所以检查产物在入队时就地冻结,放行只搬运、不重算。 +/// 它**就是 `queue.enqueued` 的投影**([`PendingTurn::from_event`] / [`PendingTurn::enqueued_event`]): +/// 字段与事件载荷一一对应,宿主不为它另存第二份产物——canonical 形状与 prompt 都在放行时从这条条目 +/// 重投影。在队 = 事件窗口里"有 `queue.enqueued`、没有配对 `queue.removed`",成员与顺序因此只从事件 +/// 折出来,没有"事件说在队、队列表说不在"的中间态。 #[derive(Clone, Debug)] pub(crate) struct PendingTurn { /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 pub(crate) client_turn_id: String, /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 pub(crate) user_item: DirectCodexUserItem, - /// canonical 用户条目的 JSON 形状:入队时算好,放行时直接落盘。 - pub(crate) canonical_user_item: Value, - /// 入队检查产出的 prompt:放行不重算。 - pub(crate) prompt: String, pub(crate) creation_type: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, } impl PendingTurn { - /// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。 - /// - /// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里 - /// (canonical 形状与 prompt 都是)。 - pub(crate) fn prepare( + pub(crate) fn new( client_turn_id: String, user_item: DirectCodexUserItem, - prompt: String, creation_type: Option, at: u64, - ) -> Result { - let canonical_user_item = serde_json::to_value(&user_item)?; - Ok(Self { + ) -> Self { + Self { client_turn_id, user_item, - canonical_user_item, - prompt, creation_type, at, + } + } + + /// 从 `queue.enqueued` 事件折出这条待发消息;其它事件返回 `None`。 + pub(crate) fn from_event(event: &ThreadEvent) -> Option { + let ThreadEvent::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } = event + else { + return None; + }; + Some(Self { + client_turn_id: client_turn_id.clone(), + user_item: user_item.clone(), + creation_type: creation_type.clone(), + at: *at, }) } - /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 只留在宿主的 - /// 队列条目里,它不是要下发的展示形状)。 + /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 是入队检查的 + /// 产物,放行时由条目重投影,不是要下发的展示形状),也不带埋点身份(成绩由宿主自己结算)。 pub(crate) fn enqueued_event(&self) -> ThreadEvent { ThreadEvent::queue_enqueued( self.client_turn_id.clone(), @@ -119,14 +127,12 @@ mod tests { } fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingTurn { - PendingTurn::prepare( + PendingTurn::new( client_turn_id.to_string(), user_item("生成一个游戏", "direct-codex:turn-1:user"), - "生成一个游戏".to_string(), creation_type.map(str::to_string), 1_700_000_000_123, ) - .expect("prepare pending turn") } /// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物, @@ -162,18 +168,21 @@ mod tests { assert_eq!(event.queue_removal_reason(), None); } - /// canonical 形状在入队时就冻结:放行时落盘的就是这一份,不再重算。 + /// 条目就是事件投影:由自己的入队事件折回来的条目与原件逐字段相同。 #[test] - fn prepare_freezes_the_canonical_item() { - let turn = pending("turn-1", None); - assert_eq!( - turn.canonical_user_item["id"], - json!("direct-codex:turn-1:user") - ); - assert_eq!( - turn.canonical_user_item, - serde_json::to_value(&turn.user_item).expect("serialize user item") - ); + fn pending_turn_round_trips_through_its_enqueued_event() { + for turn in [pending("turn-1", Some("web-game")), pending("turn-2", None)] { + let restored = + PendingTurn::from_event(&turn.enqueued_event()).expect("queue.enqueued projects"); + assert_eq!(restored.client_turn_id, turn.client_turn_id); + assert_eq!(restored.creation_type, turn.creation_type); + assert_eq!(restored.at, turn.at); + assert_eq!( + serde_json::to_value(&restored.user_item).expect("serialize user item"), + serde_json::to_value(&turn.user_item).expect("serialize user item") + ); + assert!(PendingTurn::from_event(&ThreadEvent::turn_started(1)).is_none()); + } } /// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs index 16ba49cfe..f5d328482 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs @@ -354,7 +354,8 @@ pub(crate) enum ThreadEvent { /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 /// - /// 事件不带 prompt:prompt 是入队检查的产物,只留在宿主的队列条目里。 + /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 + /// 从这条条目重投影。 #[serde(rename = "queue.enqueued")] QueueEnqueued { /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 From dc7feec426cf663788225cd609ee9f1b295ff6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 16:39:45 +0800 Subject: [PATCH 53/60] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E9=98=9F=E5=88=97=E6=9D=A1=E7=9B=AE=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E3=80=81resolved=5Ftext=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=E5=9F=8B=E7=82=B9=E5=BD=92=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20-=20ADR=20=E7=8A=B6=E6=80=81=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=202026-09-30=20=E8=90=BD=E5=9C=B0=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=8C=C2=A72=20=E8=A1=A5=E5=85=A5=E9=98=9F=E4=BE=A7?= =?UTF-8?q?=E7=9A=84=E2=80=9C=E7=94=A8=E6=88=B7=E6=9D=A1=E7=9B=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E5=86=BB=E7=BB=93=E2=80=9D=EF=BC=8C=C2=A75?= =?UTF-8?q?=20=E5=9F=8B=E7=82=B9=E6=AE=B5=E5=8E=BB=E6=8E=89=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E4=BE=A7=20discard=20=E4=B8=8E=E5=8F=A5=E6=9F=84?= =?UTF-8?q?=E8=A1=A8=E6=8E=AA=E8=BE=9E=20-=20ADR=20=E8=AF=8D=E8=A1=A8?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=EF=BC=9A=E5=B9=B6=E5=8F=91=E6=8B=92=E5=8D=95?= =?UTF-8?q?/=E6=94=BE=E8=A1=8C=E8=A1=A5=E6=8B=92=E5=8D=95=E5=87=BA?= =?UTF-8?q?=E5=8F=A3/=E6=94=BE=E8=A1=8C=E6=9C=9F=E6=8B=92=E5=8D=95?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E6=94=B9=E6=88=90=E5=85=A5=E9=98=9F=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8E=E6=94=BE=E8=A1=8C=E5=A4=B1=E8=B4=A5=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=20-=20ADR=20=C2=A79=20=E4=B8=8E=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E5=90=8C=E6=AD=A5=E5=BD=92=E4=BD=8D=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E7=AC=A6=E5=8F=B7=E5=90=8D=EF=BC=9Aactive=5Fturn=5Fid?= =?UTF-8?q?=5Fat=E3=80=81stale=5Fturn=5Ffor=5Frelease=E3=80=81kick=5Fqueue?= =?UTF-8?q?=5Fdispatch=E3=80=81complete=5Fturn=20-=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E7=8A=B6=E6=80=81=E6=94=B9=E4=B8=BA=E7=AC=AC?= =?UTF-8?q?=200=E2=80=937=20=E6=AD=A5=E5=85=A8=E9=83=A8=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=8C=E8=BF=9B=E5=BA=A6=E8=A1=A8=E7=AC=AC=206/7=20=E6=AD=A5?= =?UTF-8?q?=E8=A1=A5=E8=90=BD=E5=9C=B0=E8=AF=B4=E6=98=8E=E4=B8=8E=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=8F=B7=EF=BC=8C=E6=96=B0=E5=A2=9E=E3=80=8C2026-09-3?= =?UTF-8?q?0=20=E6=94=B6=E5=8F=A3=E8=AE=B0=E5=BD=95=E3=80=8D=20-=20?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E9=A1=B6=E9=83=A8=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=8F=A3=E5=BE=84=E8=A1=A5=20thread=5Fmanager=20?= =?UTF-8?q?=E4=B9=8B=E5=A4=96=E5=8E=BB=E6=8E=89=20direct=5F=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E7=9A=84=E5=87=BD=E6=95=B0=E6=94=B9=E5=90=8D=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=20-=20decision-log=202026-09-30=20=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E6=8A=8A=E2=80=9C=E8=AE=BE=E8=AE=A1=E7=A8=BF=EF=BC=8C=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E5=AE=9E=E6=96=BD=E2=80=9D=E6=8D=A2=E6=88=90=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E6=8F=90=E4=BA=A4=E9=93=BE=E4=B8=8E=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=8C=E5=B9=B6=E8=A1=A5=2009-24=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E6=8C=87=E5=90=91=E8=90=BD=E5=9C=B0=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...命令入队化与待发消息队列归宿主-2026-09-24.md | 26 +++++++++---------- .../shared-memory/decision-log.md | 18 ++++++++++--- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 23 +++++++++++++--- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index d3fcc9a62..4ce2c9e2b 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -1,8 +1,8 @@ # 【ADR】DirectProject命令入队化与待发消息队列归宿主 -状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**;**2026-09-30 修订**:队列条目不再另存产物, +状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**,含当日修订条目:队列条目不再另存产物, 引用的解析文本持久化进条目、`prompt` 与 canonical 形状放行时重投影,埋点候选 / settle 两阶段链退役 -(见 §3 / §4 / §5 与「备选方案与取舍」6–9 条)。实施顺序与验收见 +(见 §3 / §4 / §5 与「备选方案与取舍」6–9 条)。实施顺序、落地进度与验证证据见 [`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) ## 背景 @@ -32,11 +32,11 @@ ### 2. 命令 = 入队 `chat_with_game_creator_direct_codex` 改义改名(建议 `enqueue_direct_codex_turn`),主体是今天"接单前"那条链**原样**跑完: -`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备;通过后**只入队**—— +`clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结(引用解析文本写进条目,§5)→ 前置条件 → 工程准备;通过后**只入队**—— 追加 `queue.enqueued`,不登记占用、不落盘用户条目、不发回合事件、不起 codex。 任何一步失败就是**入队失败**,走命令返回的 typed 载荷(`DirectTurnRejection` → `DirectTurnEnqueueFailure`),用户就在现场。 -入队按 `clientTurnId` 幂等,判重范围是"在队 ∪ 正在跑的那一轮";重复入队返回同一次成功,不再是并发拒单。 +入队按 `clientTurnId` 幂等,判重范围是"在队 ∪ 正在跑的那一轮";重复入队返回同一次成功,不再是并发入队失败。 ### 3. 队列归 Thread Manager @@ -62,7 +62,7 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 并在同一临界区写 `queue.removed{ dispatched }`(chip 消失与气泡出现在同一批 consume 里,中间没有空窗)。 **放行不重跑任何检查,也不存在"放行失败"这种状态**:放行之后的一切失败都是**回合失败**,走既有 `turn.completed.failure` 通道; -不新增任何失败通道,也不为放行补拒单出口。 +不新增任何失败通道,也不为放行补失败出口。 prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入。引用 part 的解析文本(素材摘要 + 引用 UI 设计文档时要展开的 代码上下文,渲染它要往项目里写 `ui/generated-*.js`)由入队检查写进该 part 自己的 `resolved_text`, @@ -104,20 +104,20 @@ prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入。引 - `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:五个读者 (`direct_execution` / `direct_tool_bridge` / `direct_validation` / `direct_project_context` / 付费美术重生成) - 已改读 Thread Manager 的活动回合身份(`direct_active_turn_id_at`),守卫连同它的测试一起删掉,不再有第二份 + 已改读 Thread Manager 的活动回合身份(`active_turn_id_at`),守卫连同它的测试一起删掉,不再有第二份 进程内记录。 - 删守卫的前置判据**已核实(2026-09-30)**,不需要新增"取消路径补无条件终态":`d833ca9d3` 的兜底路径本来就 同时做三件事——释放占用、往事件流补一条 `turn.completed{aborted}`(否则前端会永远停在运行中)、调 - `complete_direct_thread_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; + `complete_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; 唯一独有的东西是 60 秒启动窗口闸门,改挂在 Thread Manager 的占用登记年龄上 - (`direct_stale_turn_for_release` 的 `NeverReachedExecutor` 分支)。连接死亡那条失败事实由 app-server + (`stale_turn_for_release` 的 `NeverReachedExecutor` 分支)。连接死亡那条失败事实由 app-server 连接层自己落地(`ab970b9fd`),与守卫无关。 ## 备选方案与取舍 1. **状态归宿主、放行留前端**(再用一条 `claim` 命令做 CAS 认领):前端机器原样保留,但队列的继续推进依赖至少一个窗口活着, 且"谁去认领"要靠竞态解决——正是要消掉的东西。作废。 -2. **入队只做形状校验、把前置检查留到放行**:会造出"放行期拒单"——一个没有调用方在等的失败,得为它发明新通道; +2. **入队只做形状校验、把前置检查留到放行**:会造出"放行失败"这种状态——一个没有调用方在等的失败,得为它发明新通道; 而且"首个回合还在建工程、第二条已经排队"这类合法流程会被入队误拒。作废(检查跟着入队走)。 3. **一条 `queue.changed{items:[…]}` 快照事件**代替两条细粒度事件:reducer 更傻,但每次变更搬全量、与既有细粒度事件风格不一致。不选。 4. **`reason` 用字符串**:前端只能猜、无法穷举、无法在类型层穷尽分支。不选,用 typed 枚举。 @@ -153,10 +153,10 @@ prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入。引 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 - **埋点**:成绩由宿主在回合终态直接写,中间不再有候选与 settle:放行那一刻读一次 `identity_generation`、终态再读一次,变了就整条不记。 - 于是前端不再生成、持有或回传任何埋点身份(`analyticsAttemptId` 命令参数、`beginDirectRunAnalytics`、句柄表与 - "入队失败就删掉句柄"一并退役),队列事件也不需要埋点字段。判据口径随放行搬家:从"发送时与终态同代"改成"放行时与终态同代", - 入队后、放行前发生的账号切换因此不再丢弃这一轮的成绩——那一轮确实是在新身份下跑的。 - 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 + 于是前端不再生成、持有或回传任何埋点身份(`analyticsAttemptId` 命令参数、`beginDirectRunAnalytics`、句柄表、 + settle 调用与"入队失败就删掉句柄"一并退役),队列事件也不需要埋点字段。判据口径随放行搬家:从"发送时与终态同代" + 改成"放行时与终态同代",入队后、放行前发生的账号切换因此不再丢弃这一轮的成绩——那一轮确实是在新身份下跑的。 + 代际比对本身也不再是渲染侧职责:放行侧读一次代次、终态侧再读一次,都在宿主内完成。 ## 明确不做 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 23dc9e0c1..c01d2a426 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9116,8 +9116,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 代价:入队即写盘(引用 UI 设计文档的条目会生成 `ui/generated-.js`,取消不撤);检查是时间点事实、 放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化; 失去"真实二进制驱动执行层生产验证"这条手工路径。 -- 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、 - `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 +- 验证方式:设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md` 与 + `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`;落地提交与验证证据 + 见下方 2026-09-30 三条,以及该实施计划的「落地进度」与「2026-09-30 收口记录」。 ## 2026-09-30 守卫清理前置判据核实:取消兜底不需要新增无条件终态,60 秒闸门改挂占用登记年龄 @@ -9145,7 +9146,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-30 待发消息条目不再另存产物:队列成员由事件折出,prompt 改为条目重投影 -- 背景:09-24 落地时给 `StoredEvent` 加了一个"挂着的队列条目"字段(`Option`),条目上带七个字段。 +- 背景:09-24 落地时给 `StoredEvent` 加了一个"挂着的队列条目"字段(`Option`, + 归位后是 `agent/thread_manager/mod.rs` 的 `StoredEvent.pending: Option`),条目上带七个字段。 复核发现其中 `client_turn_id` / `user_item` / `creation_type` / `at` 与 `queue.enqueued` 的载荷是同一份事实的第二份拷贝, `canonical_user_item` 是 `serde_json::to_value(user_item)`(第三份),而这两项在构造事件之后再没被读过; `Some/None` 同时兼着"宿主产物袋"与"在队标记"两职,于是取消 / 放行要写两处事实、回收规则还要加"产物还在就不许回收"的防御。 @@ -9166,7 +9168,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 于是 `analyticsAttemptId` 命令参数、候选表 `pending_runs`、`settle_direct_run_analytics`、前端 `beginDirectRunAnalytics` 与句柄表一起删,`queue.enqueued` 也不需要埋点字段。判据口径随放行搬家: 从"发送时与终态同代"改成"放行时与终态同代"——入队后、放行前发生的账号切换不再丢弃这一轮的成绩。 -- 验证方式(设计稿,尚未实施):ADR §3 / §4 / §5 与「备选方案与取舍」6–8 条、实施计划第 6 步。 +- 落地(`2b98b022c` 文档 → `15a495dc2` `resolved_text` 与 prompt 拆 freeze / 纯折叠 → `0270cd601` 埋点归宿主 + → `28b64cf25` 条目改事件投影):`PendingTurn` 只留 `client_turn_id` / `user_item` / `creation_type` / `at` 并配 + `from_event` / `enqueued_event` 往返;`StoredEvent` 上没有队列字段,`pending_turns` 一次折叠出成员与顺序; + `freeze_direct_codex_user_item` 是入队侧唯一算片段 / 写盘处,`direct_codex_user_item_to_prompt(&item)` 变纯折叠; + `direct_finished` 收放行代次并直写 `Request::Terminal`。 +- 验证:`cargo test -- thread_manager::`(62 passed,进程级计数器用例单线程)、`-- agent::`(948 passed)、 + `-- direct_runtime:: analytics:: direct_codex_user_item`(182 passed);`analytics::store_tests` 换成"终态直写" + 与"放行后代际变化不记"两条用例;`npx vitest run tests/appSurface.test.ts`(194 passed)与 chat / direct 定向套件 + (249 passed);`npm --workspace apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、`git diff --check`。 ## 2026-09-28 渲染层下沉分支与最新 master 对齐:活动回合事实源、维护态出口与诊断详情 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index e8448b384..cf6e3b0c2 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,11 +2,14 @@ 更新时间:`2026-09-24` -状态:**第 0–5 步已落地;第 6、7 步进行中**(2026-09-30 修订) +状态:**第 0–7 步全部落地**(2026-09-30 修订并收口) > 文件路径口径(2026-09-30):本文里 2026-09-24 写的 `direct_thread_manager.rs` / `direct_thread_wire.rs` / > `direct_thread_queue.rs` / `direct_turn_dispatch.rs` 现在分别是 `agent/thread_manager/{mod,wire,queue,dispatch}.rs`, > 类型与函数去掉 `Direct` 前缀(`ThreadEvent`、`PendingTurn`、`subscribe_thread` …,见提交 `f121257cc`)。 +> 同一次归位里 thread_manager 之外的几个函数也去掉了 `direct_` 前缀:`direct_active_turn_id_at` → `active_turn_id_at`、 +> `direct_stale_turn_for_release` → `stale_turn_for_release`、`kick_direct_queue_dispatch` → `kick_queue_dispatch`、 +> `complete_direct_thread_turn` → `complete_turn`。 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -21,8 +24,8 @@ | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | | 第 5 步 守卫清理 | 已落地 | 五个身份读者改读 `direct_active_turn_id_at`;删 `DirectTaonierActiveInvocationGuard` / `DirectActiveTurnView` / 只读探测与 `release_stale_direct_taonier_active_invocation`(改 `direct_stale_turn_for_release` 只做前置校验);删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定;补前置判据用例 | -| 第 6 步 条目不再另存产物 | 进行中 | 删 `StoredEvent.pending` 与 `PendingTurn` 的存储形态(改事件投影);引用解析文本落 `AgcResourceReference.resolved_text`(持久化);`canonical` 与 `prompt` 放行时重投影 | -| 第 7 步 埋点成绩归宿主 | 进行中 | 删候选 / settle 两阶段与 `analyticsAttemptId`(含命令参数、前端句柄表与结算 effect);放行读 `identity_generation`、终态比对后直写 | +| 第 6 步 条目不再另存产物 | 已落地 | `PendingTurn` 就是 `queue.enqueued` 的投影(`from_event` / `enqueued_event` 往返),删掉 `canonical_user_item` 与 `prompt` 两个宿主私有产物字段;`StoredEvent` 上没有队列条目字段(`pending_turns` 一次事件折叠);引用解析文本落 `AgcResourceReference.resolved_text` 并随条目持久化;`canonical` 与 `prompt` 放行时重投影(提交 `15a495dc2` / `28b64cf25`) | +| 第 7 步 埋点成绩归宿主 | 已落地 | 删候选 / settle 两阶段与 `analyticsAttemptId`(`Request::DirectCandidate` / `Request::Settle` / `settle` / `pending_runs` / `settle_direct_run_analytics` / 前端句柄表与结算 effect);放行读一次 `identity_generation`、终态再读一次后直写 `Request::Terminal`(提交 `0270cd601`) | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -234,3 +237,17 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem `kill -9` 后重进队列消失(与 ADR 的已知边界一致)。 - 全仓:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 - 不涉及 SpacetimeDB schema,不需要 `npm run check:spacetime-schema`。 + +## 2026-09-30 收口记录 + +第 6、7 步按上面两节落地,提交按"文档定稿 → 引用解析文本持久化 → 埋点归宿主 → 条目改事件投影"切开: +`2b98b022c`(文档)、`15a495dc2`(`resolved_text` 与 prompt 拆 freeze / 纯折叠)、`0270cd601`(埋点成绩归宿主)、 +`28b64cf25`(条目改事件投影)。 + +- Rust:`cargo test --bin genarrative-ai-game-creator-shell -- thread_manager::`(62 passed,进程级计数器用例单线程跑)、 + `-- agent::`(948 passed;`design_runtime` 与两条历史并发用例在整包并行下偶发,单跑通过,与本次改动无关)、 + `-- direct_runtime:: analytics:: direct_codex_user_item`(182 passed)、`-- analytics_real_file_write_...`(1 passed)。 +- Node:`npx vitest run tests/appSurface.test.ts`(194 passed)、`tests/directThreadChat.test.ts`、 + `tests/directHistoryPaging.test.ts`、`tests/directProjectTurn.test.ts` 等 chat / direct 单测(249 passed)、 + `npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 +- 全仓:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 From c6381f68e1f31a96fa684f0223cfbafd9f4f4e67 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 30 Sep 2026 18:11:17 +0800 Subject: [PATCH 54/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=A3=B3CI=E7=BC=BA?= =?UTF-8?q?=E5=B0=91Claude=20SDK=E4=BE=9D=E8=B5=96=E5=AE=89=E8=A3=85=20(#5?= =?UTF-8?q?58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为两条Rust测试lane及smoke任务补齐编译前npm安装 为Rust缓存预热补齐Claude SDK依赖安装 更新CI安装顺序验证及开发运维文档和共享记忆 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/558 Co-authored-by: Linghong Co-committed-by: Linghong --- .gitea/workflows/project-ci.yml | 15 ++++++++--- .../tests/prompt_source_boundaries.rs | 2 +- .../shared-memory/development-workflow.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +++--- scripts/build-gitea-rust-cache.sh | 2 ++ scripts/project-ci-workflow.test.ts | 25 +++++++++++++------ 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 8338196cf..a5694037f 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -47,8 +47,8 @@ env: # 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢 # 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。 jobs: - # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm - # 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。 + # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片; + # build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。 ai-game-creator-shell-rust-lane-1: name: AI game creator shell Rust lane 1/2 runs-on: genarrative-ci @@ -62,6 +62,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -118,6 +121,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -154,7 +160,7 @@ jobs: GENARRATIVE_GITEA_TOKEN: ${{ github.token }} run: python3 scripts/export-gitea-rust-cache.py - # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: name: AI game creator shell Rust smoke @@ -169,6 +175,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | diff --git a/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs index 453b77e00..5ed98b980 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs @@ -258,7 +258,7 @@ fn prompt_entry_points_load_their_prose_from_external_files() { ), ( "src/agent/direct_codex_user_item/wire.rs", - &["render_ui_design_code_context"], + &["ui_design_code_context_for"], ), ( "src/commands.rs", diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6b9ba3efb..29b6c172d 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -114,4 +114,4 @@ Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-ga AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 -`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 +`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane 与 smoke job 必须在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`:AGC 壳的 `build.rs` 会从 `node_modules` 准备 Claude Agent SDK 与目标平台原生运行时,镜像中的 npm 下载缓存不能替代安装。人工 `scripts/build-gitea-rust-cache.sh` bootstrap 同样在首次编译 AGC 壳前安装 npm 依赖;只有不构建壳的 crates job 继续省略 npm 安装。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5315d44f8..4f86ce479 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -740,6 +740,8 @@ Prompt 静态门禁必须断言上述 Bundle section 当前定义的权威语义 Agent 可见的系统指令、工具与参数说明、恢复指引和上下文模板统一由外置提示词文件维护。AGC 沿用 `prompts/runtime/manifest.json`:已有 composition/section 保持原有组合关系,独立调用的文本按职责登记在 `textCatalogs`,目录为 `prompts/runtime/texts/`,每份 JSON 是稳定文本键到正文的映射。构建期校验目录、文件、重复键和空正文,并生成可供 `format!` 使用的编译期文本宏;变量填充沿用 Rust 格式语法。Runtime 状态、用户内容、schema 类型与枚举、权限和校验继续由代码生成。服务端 Agent 的独立 crate 使用各自 `prompts/` 中的编译期文本文件。迁移以当前组装结果和工具 schema 等价为验收依据,源码门禁检查各提示词入口的内联正文与外置引用。 +`tests/prompt_source_boundaries.rs` 的入口清单随生产函数迁移同步更新。UI 设计文档代码上下文在入队冻结时由 `src/agent/direct_codex_user_item/wire.rs::ui_design_code_context_for` 生成,门禁检查该函数继续使用外置的 `projectContext.uiDesign.codeContext` 与 `projectContext.uiDesign.generationErrorContext`;旧 `render_ui_design_code_context` 已随冻结与纯投影拆分删除,不保留兼容入口。 + 2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](<./【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md>) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。 同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。对一次性 `command.exec` 而言,只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。这里对 PTY 和后台进程的排除仅适用于 `command.exec`,不能用来否定 V1.10 的独立持久进程工具,也不能把 `command.exec` 自身改成长驻入口。`command.exec` 的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gate,Git / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 50ceb846d..7946645c1 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -329,8 +329,8 @@ Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建 - `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`、`cargo test --locked -p spacetime-module --no-fail-fast`、`api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 - `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract`、`npm run check:native-shells:shells` 和 `npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。 - `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`:AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。 -- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-`(每次分片运行器使用对应的 `--shard-index=`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 只用 cargo 与 node 内建模块,因此不装 npm 依赖。 -- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;只用 cargo 与 node 内建模块(脚本只 import `node:*`),因此不装 npm 依赖。 +- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-`(每次分片运行器使用对应的 `--shard-index=`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`,为 `build.rs` 准备 Claude Agent SDK 与 Linux 原生运行时。 +- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;smoke 脚本自身只 import `node:*`,但壳的 `build.rs` 需要 Claude Agent SDK,因此同样在编译前执行根 `npm ci`。 - `AI game creator shell Rust crates`:预热 `server-rs/Cargo.toml` 与两个无锁独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)后执行 `npm run check:native-shells:agc-rust-crates`(即 `npm run ai-game-creator-shell:check:rust:crates`),覆盖 `agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm` 与 `shared-contracts`。这四条命令用的是 server-rs workspace 与独立 crate 的 manifest,属另一套依赖图,因此单独一个 job,也只跑 cargo、不装 npm 依赖。 九个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs` 的 `--groups=`:十个分组(`contract`、`shells`、`agc-web`、`agc-rust-crates`、`agc-rust-shard-1` ~ `agc-rust-shard-4`、`agc-rust-smoke`、`release`)各自对应一个 `check:native-shells:` 根脚本,并在 workflow 的某个 lane/job 里被恰好调用一次;每个 Rust lane 顺序调用两组,不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容、lane/job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 @@ -354,7 +354,7 @@ bash scripts/gitea-ci-job-image.sh load-runner 执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的九个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 -九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane、`AI game creator shell Rust smoke` 与 `AI game creator shell Rust crates` 是纯 cargo 门禁(只用 cargo 与 node 内建模块),显式不装 npm 依赖,这也由 `scripts/project-ci-workflow.test.ts` 钉住。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 +九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane 与 `AI game creator shell Rust smoke` 也必须在编译前安装 npm 依赖,因为 `build.rs` 会准备随包 Claude Agent SDK 与目标平台原生运行时;只有不构建壳的 `AI game creator shell Rust crates` 省略 npm 安装。安装覆盖与编译前顺序由 `scripts/project-ci-workflow.test.ts` 验证。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。 @@ -368,7 +368,7 @@ master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Pro AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试均启用 sccache。Native shell 的 release build smoke 显式清空两个 wrapper,保持发布构建原有 profile/features 与资源 staging;前端和 repository checks 不启用对象缓存。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。 -人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中只编译预热,不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。 +人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中先通过 `scripts/ci-npm-ci-with-retry.sh` 安装根 npm 依赖,再编译预热 AGC 壳,以满足 Claude Agent SDK 随包资源的构建要求;不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。 各 Rust job 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 4 GiB,快照构建完成后输出实际体积;最终测试 bin 的链接仍须执行。全组开启时必须生成覆盖全部目标的新快照,不能把旧 AGC 单目标快照当作后端/桌面壳的预热验收。 diff --git a/scripts/build-gitea-rust-cache.sh b/scripts/build-gitea-rust-cache.sh index 1bd6a22aa..ea96b2bcb 100644 --- a/scripts/build-gitea-rust-cache.sh +++ b/scripts/build-gitea-rust-cache.sh @@ -107,6 +107,8 @@ done echo '[rust-cache] warming desktop tests and AGC prompt contracts' cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run +# AGC build.rs 从 node_modules 准备 Claude Agent SDK 与目标平台原生运行时。 +bash scripts/ci-npm-ci-with-retry.sh cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ --test runtime_prompt_bundle_build --test prompt_source_boundaries --no-run diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 6f793634a..283f6a53a 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -67,14 +67,9 @@ const jobNames = [ 'ai-game-creator-shell-rust-crates', ] as const; -// 这几个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与 -// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令, -// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径 -// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。 +// crate 级测试只跑共享 / 平台 / 编辑器插件的 Cargo manifest,不构建 AGC 壳。 +// 壳的 Rust 分片与 agent-run smoke 会执行 build.rs,需要 npm 安装的 Claude Agent SDK。 const jobsWithoutNpmInstall: readonly string[] = [ - 'ai-game-creator-shell-rust-lane-1', - 'ai-game-creator-shell-rust-lane-2', - 'ai-game-creator-shell-rust-smoke', 'ai-game-creator-shell-rust-crates', ]; @@ -199,6 +194,13 @@ describe('project CI workflow', () => { section.indexOf('Checkout full history from Gitea'), ); expect(prepare).toBeLessThan(section.indexOf(`- name: ${firstRustStep}`)); + if (!jobsWithoutNpmInstall.includes(job)) { + const npmInstall = section.indexOf( + 'bash scripts/ci-npm-ci-with-retry.sh', + ); + expect(npmInstall).toBeGreaterThanOrEqual(0); + expect(npmInstall).toBeLessThan(prepare); + } expect( stepSection(job, 'Report isolated Rust compilation cache'), ).toContain('if: always()'); @@ -246,6 +248,15 @@ describe('project CI workflow', () => { expect(warmCommands).toContain( 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run', ); + const npmInstall = warmCommands.indexOf( + 'bash scripts/ci-npm-ci-with-retry.sh', + ); + expect(npmInstall).toBeGreaterThanOrEqual(0); + expect(npmInstall).toBeLessThan( + warmCommands.indexOf( + 'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ), + ); }); it('runs for master pushes, pull requests, and manual dispatch only', () => { From 66ff5bf4b702f19d6f2b203960b728c52905de31 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:59:26 +0800 Subject: [PATCH 55/60] =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E7=9A=84=E6=8F=92=E4=BB=B6=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E8=AF=8A=E6=96=AD=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除内置插件卡片中的 lastError 展示 移除导入插件卡片中的 lastError 展示 补充扩展设置文档约定 --- .../features/runtime-config/RuntimeConfigDialog.tsx | 10 ---------- ...�技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++++ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index c18a6da4f..1b4a52659 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1113,11 +1113,6 @@ export function RuntimeConfigDialog({
{plugin.name} 内置 Plugin · 不可卸载 - {plugin.lastError ? ( - - {plugin.lastError} - - ) : null}
@@ -1258,11 +1253,6 @@ export function RuntimeConfigDialog({ {typeLabel} · 来自 {item.sourceName} - {item.lastError ? ( - - {item.lastError} - - ) : null}
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4f86ce479..f65a82ef2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -91,6 +91,10 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema | 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` | | 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` | +## 2026-09-30 扩展设置隐藏插件进程诊断文案 + +设置→扩展的内置插件与导入插件卡片不再渲染宿主返回的 `lastError`(包括“插件进程已退出”)。插件启用状态、运行状态、重载与启用/禁用操作保持现有行为;错误仍由宿主保留用于状态判定和操作反馈。 + ## 2026-09-20 DirectProject 七项效率闭环(补齐合同) 本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。 From d2d78fa3d4296e9dfbfeb37b8ff41fd770fd75af Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 19:12:30 +0800 Subject: [PATCH 56/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90staging=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 --no-bundle 构建前的资源准备接线。 拒绝插件随包目录中的未声明文件并同步Rust校验。 增加staging替换失败后的旧资源恢复与回归测试。 --- .../scripts/build-release.mjs | 3 +- .../scripts/build-release.test.mjs | 2 + .../scripts/check-package-layout.mjs | 1 + .../scripts/prepare-bundled-resources.mjs | 104 +++++- .../prepare-bundled-resources.test.mjs | 333 ++++++++++++++++++ .../build_support/package-layout.generated.rs | 1 + .../src-tauri/build_support/package_layout.rs | 329 +++++++++++++++-- 7 files changed, 733 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index eccab786c..00f144de6 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -505,10 +505,11 @@ export function runTauriBuild( const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; const features = resolveEditorFeatures({ argv: args, target }); + // --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。 if (!args.includes('--no-bundle')) { stageRuntime(target); - stageBundled(target, { features }); } + stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index dcb19454d..e6438ed7f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1001,6 +1001,7 @@ test('release stages Node before Tauri and injects its resource mapping only for events.push('bundled'); }, spawn(_binary, args) { + events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); @@ -1013,6 +1014,7 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); + assert.deepEqual(events, ['bundled', 'build']); assert.throws( () => runTauriBuild(['--target', windowsTarget], context, { diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 0928c0b7f..5682da895 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -485,6 +485,7 @@ function renderLibraryStaging(entry) { ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], ['layout', rustString(entry.layout)], + ['files', rustStrings(entry.files)], ], 1, ); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 6212becba..f22e34c00 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -488,8 +488,11 @@ function buildUnitInto( } function stageAtomically(unitPath, builder) { - const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`; + const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`; + const stagingPath = `${unitPath}-staging-${suffix}`; + const backupPath = `${unitPath}-backup-${suffix}`; rmSync(stagingPath, { recursive: true, force: true }); + rmSync(backupPath, { recursive: true, force: true }); mkdirSync(stagingPath, { recursive: true }); try { builder(stagingPath); @@ -497,15 +500,31 @@ function stageAtomically(unitPath, builder) { rmSync(stagingPath, { recursive: true, force: true }); throw error; } - rmSync(unitPath, { recursive: true, force: true }); + + const hadPrevious = existsSync(unitPath); try { + if (hadPrevious) { + renameSync(unitPath, backupPath); + } renameSync(stagingPath, unitPath); } catch (error) { - // 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。 + let restored = !hadPrevious; + if (hadPrevious && existsSync(backupPath)) { + try { + if (existsSync(unitPath)) { + rmSync(unitPath, { recursive: true, force: true }); + } + renameSync(backupPath, unitPath); + restored = true; + } catch { + restored = false; + } + } fail( - `替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`, + `替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`, ); } + rmSync(backupPath, { recursive: true, force: true }); } function prepareCodex({ @@ -860,6 +879,30 @@ function subdirectoryEnabled(subdirectory, target, features) { return matchesContains && matchesTarget && matchesFeatures; } +function collectTreeFiles(root, label) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + /// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。 function skipDirectory(rules, name) { return ( @@ -958,7 +1001,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const staging of declaration.plugins.libraryStaging ?? []) { if ( plugin.name !== staging.plugin || - libraryStagingEnabled(staging, target, features) + !libraryStagingEnabled(staging, target, features) ) { continue; } @@ -978,7 +1021,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const payload of declaration.plugins.nativePayloads ?? []) { if ( plugin.name !== payload.plugin || - nativePayloadEnabled(payload, target, features) + !nativePayloadEnabled(payload, target, features) ) { continue; } @@ -1007,8 +1050,11 @@ function pluginTreeMatches( if (!existsSync(destination)) { return false; } + const allowedFiles = new Set(); for (const plugin of plugins) { const pluginDestination = path.join(destination, plugin.name); + const pluginPrefix = `${plugin.name}/`; + allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`); if ( !existsSync( path.join(pluginDestination, declaration.plugins.manifestFileName), @@ -1022,17 +1068,24 @@ function pluginTreeMatches( } const stagedDirectory = path.join(pluginDestination, subdirectory.path); if (!subdirectoryEnabled(subdirectory, target, features)) { - // 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。 if (existsSync(stagedDirectory)) { return false; } continue; } + const relativePrefix = `${plugin.name}/${subdirectory.path}/`; const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { return false; } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + allowedFiles.add(`${relativePrefix}${relative}`); + } continue; } for (const relative of walkFiles( @@ -1046,6 +1099,7 @@ function pluginTreeMatches( subdirectory.path, relative, ); + allowedFiles.add(`${relativePrefix}${relative}`); if (!existsSync(staged)) { return false; } @@ -1057,8 +1111,32 @@ function pluginTreeMatches( } } } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name === staging.plugin && + libraryStagingEnabled(staging, target, features) + ) { + for (const relative of staging.files) { + allowedFiles.add( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, + ); + } + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name === payload.plugin && + nativePayloadEnabled(payload, target, features) + ) { + allowedFiles.add( + `${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`, + ); + } + } } - return true; + return collectTreeFiles(destination, '插件随包目录').every((relative) => + allowedFiles.has(relative), + ); } function assertOwnedPluginRoot(destination, plugins) { @@ -1504,10 +1582,16 @@ function preparePlugins({ }); return { summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, - record: { fingerprint }, + record: { + fingerprint: pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ), + }, }; } - /// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 export function prepareBundledResources({ target = resolveHostTarget(), diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 5e3c6f54a..c808ab43c 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -286,6 +287,41 @@ function prepare(fixture, overrides = {}) { }); } +/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。 +/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门; +/// body 是同步的,注入窗口内不会有别的调用跑进来。 +function withRenameFailure(predicate, body) { + const original = fs.renameSync; + let injected = false; + fs.renameSync = (from, to) => { + if ( + !injected && + predicate(path.resolve(String(from)), path.resolve(String(to))) + ) { + injected = true; + throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' }); + } + return original.call(fs, from, to); + }; + syncBuiltinESMExports(); + try { + return body(); + } finally { + fs.renameSync = original; + syncBuiltinESMExports(); + } +} + +/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。 +function expectThrow(body) { + try { + body(); + } catch (error) { + return error; + } + throw new Error('预期抛错但调用成功了'); +} + test('stages declared codex components with manifest and preserved notice', () => { const fixture = buildFixture(); try { @@ -824,3 +860,300 @@ test('delivers editor branch artifacts declared as prepared or library staging', fixture.cleanup(); } }); + +/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。 +const ALL_WINDOWS_FEATURES = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', +]); + +function pluginStaged(fixture, relative) { + return path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + relative, + ); +} + +test('keeps the previous codex resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + declaration.codex.resourceDirectory, + layout.directory, + ); + const before = snapshot(unit); + const component = layout.files.find((relative) => + relative.includes('code-mode-host'), + ); + const vendor = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`, + ); + // 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。 + fs.writeFileSync(path.join(vendor, component), 'changed component\n'); + // 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。 + fs.rmSync(fixture.recordPath); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(unit), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*win-x64.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(unit), + before, + '替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变', + ); + assert.equal( + fs.readFileSync( + path.join(unit, declaration.codex.noticeFileName), + 'utf8', + ), + 'windows codex notice\n', + '受版本控制的第三方声明必须还在原位', + ); + const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1]; + assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`); + // 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。 + const siblings = fs.readdirSync(path.dirname(unit)); + assert.ok(siblings.includes(path.basename(unit))); + assert.ok(siblings.includes(path.basename(stagingPath))); + assert.ok( + siblings.every((entry) => !entry.includes('-backup-')), + '恢复成功后不得留下 backup 目录', + ); + assert.equal( + fs.readFileSync(path.join(stagingPath, component), 'utf8'), + 'changed component\n', + '没有落盘的新产物必须留在 staging 里供人工检查', + ); + + // 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。 + const summaries = prepare(fixture); + assert.match(summaries[0], /重新生成/u); + assert.equal( + fs.readFileSync(path.join(unit, component), 'utf8'), + 'changed component\n', + ); + } finally { + fixture.cleanup(); + } +}); + +test('keeps the previous plugin resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + // 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。 + fs.writeFileSync( + path.join(destination, 'agc-demo-editor/src/rogue.mjs'), + 'rogue\n', + ); + const before = snapshot(destination); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(destination), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*plugins.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(destination), + before, + '替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留', + ); + + prepare(fixture); + assert.ok( + !fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')), + '注入消失后重试必须成功并清掉额外文件', + ); + assert.ok( + fs.existsSync( + path.join(destination, 'agc-demo-editor/panels/panel.html'), + ), + '重试后声明的随包内容必须齐全', + ); + } finally { + fixture.cleanup(); + } +}); + +test('clears undeclared and hidden entries left in the staged plugin tree', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + // 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。 + for (const relative of [ + 'agc-demo-editor/extra/rogue.txt', + 'agc-demo-editor/.cache/tmp.bin', + 'agc-demo-editor/src/.env', + ]) { + const file = pluginStaged(fixture, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'residue\n'); + } + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + for (const relative of [ + 'agc-demo-editor/extra', + 'agc-demo-editor/.cache', + 'agc-demo-editor/src/.env', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `未声明的残留 ${relative} 必须被清掉`, + ); + } + for (const relative of [ + 'agc-demo-editor/plugin.json', + 'agc-demo-editor/src/entry.mjs', + 'agc-demo-editor/panels/panel.html', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `声明的随包内容 ${relative} 必须还在`, + ); + } + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'), + ), + '跳过规则在重建后依然生效', + ); + } finally { + fixture.cleanup(); + } +}); + +test('removes staged plugin files whose source has been deleted', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')), + '前置条件:源码里的文件已经随包', + ); + fs.rmSync( + path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'), + ); + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'), + ), + '源码里已删除的文件不得留在随包目录', + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')), + '同一插件的其他声明内容必须还在', + ); + // 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。 + assert.match(prepare(fixture)[1], /命中缓存/u); + } finally { + fixture.cleanup(); + } +}); + +test('clears staged prepared payloads when their feature is disabled', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `前置条件:feature 全开时 ${relative} 必须随包`, + ); + } + + prepare(fixture, { features: new Set() }); + for (const relative of [ + 'agc-cocos-editor/native/payload', + 'agc-unity-editor/dotnet', + 'agc-godot-editor/native', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `feature 关闭后 ${relative} 不得作为残留继续随包`, + ); + } + for (const plugin of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)), + `feature 关闭不得动到 ${plugin} 的声明内容`, + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)), + `feature 关闭不得动到 ${plugin} 的源码内容`, + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('treats declared prepared and library-staging artifacts as owned on a second run', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + const before = snapshot(destination); + + const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + assert.match( + summaries[1], + /plugins 命中缓存(未写入/u, + 'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建', + ); + assert.deepEqual( + snapshot(destination), + before, + '命中缓存时一个字节、一个时间戳都不许动', + ); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `已声明产物 ${relative} 不得因为缓存判定被删掉`, + ); + } + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index e6d626a7d..931290eb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -153,6 +153,7 @@ LibraryStaging { targets: &["x86_64-pc-windows-msvc"], features: &["godot-editor-execute"], layout: "godot-bundle", + files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"], } ], skip_directory_names: &["target", "node_modules"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 48f24c023..b334fe265 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -59,6 +59,9 @@ pub struct Subdirectory { } /// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +/// +/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与 +/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。 #[derive(Debug)] pub struct LibraryStaging { pub plugin: &'static str, @@ -66,6 +69,7 @@ pub struct LibraryStaging { pub targets: &'static [&'static str], pub features: &'static [&'static str], pub layout: &'static str, + pub files: &'static [&'static str], } #[derive(Debug)] @@ -290,8 +294,11 @@ pub fn declared_relative_path(relative: &str) -> PathBuf { /// 只读校验插件随包工作区。 /// -/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录 -/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。 +/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、 +/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256 +/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责), +/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、 +/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。 pub fn validate_staged_plugins( source_root: &Path, destination_root: &Path, @@ -307,7 +314,36 @@ pub fn validate_staged_plugins( destination_root.display() )); } - for plugin in plugin_directories(source_root, plugins().manifest_file_name)? { + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; + let declared_names = declared_plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect::>(); + for entry in std::fs::read_dir(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })? { + let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("插件随包目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + return Err(format!( + "插件随包目录不允许符号链接:{}", + entry.path().display() + )); + } + let name = entry.file_name().to_string_lossy().to_string(); + if !declared_names.contains(name.as_str()) { + return Err(format!( + "插件随包目录存在未声明条目:{}", + entry.path().display() + )); + } + } + for plugin in &declared_plugins { let staged = destination_root.join(&plugin.name); let source_manifest = plugin.path.join(plugins().manifest_file_name); let staged_manifest = staged.join(plugins().manifest_file_name); @@ -328,39 +364,34 @@ pub fn validate_staged_plugins( staged_manifest.display() )); } + let mut allowed = BTreeSet::new(); + allowed.insert(plugins().manifest_file_name.to_string()); for subdirectory in plugins().subdirectories { - if !subdirectory_enabled(subdirectory, target, &feature_enabled) { + if !subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !subdirectory_enabled(subdirectory, target, &feature_enabled) + { continue; } let relative = declared_relative_path(subdirectory.path); - let source = plugin.path.join(&relative); + let source_directory = plugin.path.join(&relative); let staged_directory = staged.join(&relative); - if subdirectory_is_prepared(subdirectory) { - if source.exists() && !staged_directory.is_dir() { - return Err(format!( - "随包构建期产物缺失:{}(插件 {})", - staged_directory.display(), - plugin.name - )); - } - continue; - } - if !source.is_dir() { - continue; - } - if !staged_directory.is_dir() { - return Err(format!( - "随包插件缺少必需目录:{}(插件 {})", - staged_directory.display(), - plugin.name - )); - } - for relative_file in collect_sources(&source)? { - let source_file = source.join(declared_relative_path(&relative_file)); - let staged_file = staged_directory.join(declared_relative_path(&relative_file)); + for file in source_subdirectory_files(&source_directory)? { + allowed.insert(format!("{}/{}", subdirectory.path, file)); + let staged_file = staged_directory.join(declared_relative_path(&file)); if !staged_file.is_file() { - return Err(format!("随包插件缺少文件:{}", staged_file.display())); + return Err(if subdirectory_is_prepared(subdirectory) { + format!( + "随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + ) + } else { + format!("随包插件缺少文件:{}", staged_file.display()) + }); } + if !subdirectory_is_source_derived(subdirectory) { + continue; + } + let source_file = source_directory.join(declared_relative_path(&file)); let source_digest = sha256_file(&source_file).map_err(|error| { format!("读取插件文件失败 {}:{error}", source_file.display()) })?; @@ -375,11 +406,45 @@ pub fn validate_staged_plugins( } } } + for staging in plugins().library_staging { + if staging.plugin != plugin.name.as_str() + || !library_staging_enabled(staging, target, &feature_enabled) + { + continue; + } + for file in staging.files { + let relative = format!("{}/{}", staging.source_subdirectory, file); + let staged_file = staged.join(declared_relative_path(&relative)); + if !staged_file.is_file() { + return Err(format!( + "随包库缺少声明文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + )); + } + allowed.insert(relative); + } + } + for relative in collect_tree_files(&staged)? { + if !allowed.contains(&relative) { + return Err(format!( + "随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)", + staged.join(declared_relative_path(&relative)).display() + )); + } + } } collect_tree_files(destination_root)?; Ok(()) } +/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。 +fn source_subdirectory_files(source_directory: &Path) -> Result, String> { + if !source_directory.is_dir() { + return Ok(BTreeSet::new()); + } + collect_sources(source_directory) +} + /// 声明为「由准备步骤按源码派生」的子目录。 pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { subdirectory.origin == "source" @@ -1124,6 +1189,29 @@ mod tests { .expect("write staged entry"); } + /// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。 + fn write_plugin_copy( + source_root: &Path, + destination_root: &Path, + plugin_name: &str, + relative_files: &[&str], + ) { + for root in [source_root, destination_root] { + let plugin = root.join(plugin_name); + fs::create_dir_all(&plugin).expect("create plugin"); + fs::write( + plugin.join("plugin.json"), + format!("{{\"name\":\"{plugin_name}\"}}\n"), + ) + .expect("write manifest"); + for relative in relative_files { + let path = plugin.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("path parent")).expect("create parent"); + fs::write(&path, format!("{relative}\n")).expect("write file"); + } + } + } + #[test] fn staged_plugins_are_checked_against_repository_sources() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1205,6 +1293,189 @@ mod tests { assert!(error.contains("符号链接"), "{error}"); } + /// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。 + #[test] + fn staged_plugins_reject_undeclared_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + + let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs"); + fs::write(&leftover, "stale\n").expect("write leftover"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝源码子目录里的残留文件"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("leftover.mjs"), "{error}"); + fs::remove_file(&leftover).expect("remove leftover"); + + let root_file = destination_root.join("agc-demo-editor/README.md"); + fs::write(&root_file, "stale\n").expect("write plugin root file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝插件根目录的未知文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&root_file).expect("remove plugin root file"); + + let stray = destination_root.join("stray.txt"); + fs::write(&stray, "stale\n").expect("write stray entry"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝未声明的根条目"); + assert!(error.contains("未声明条目"), "{error}"); + } + + /// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。 + #[test] + fn staged_plugins_reject_files_removed_from_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("源码删除后的随包副本必须被拒绝"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("entry.mjs"), "{error}"); + } + + /// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。 + #[test] + fn staged_plugins_reject_prepared_artifacts_of_disabled_features() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_copy( + &source_root, + &destination_root, + "agc-cocos-editor", + &["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"], + ); + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "cocos-editor-injection", + ) + .expect("feature 开启时 prepared 产物合法"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("cocos-editor-bridge.dll"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("目标不含 windows 时必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + } + + /// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。 + #[test] + fn staged_plugins_follow_declared_library_staging_files() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + let mut relative_files = vec!["src/entry.mjs".to_string()]; + relative_files.extend( + staging + .files + .iter() + .map(|file| format!("{}/{}", staging.source_subdirectory, file)), + ); + let relative_files = relative_files + .iter() + .map(String::as_str) + .collect::>(); + write_plugin_copy( + &source_root, + &destination_root, + "agc-godot-editor", + &relative_files, + ); + let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp"); + fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir"); + fs::write(&source_only, "void build_input() {}\n").expect("write source-only file"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect("声明文件齐备时随包库合法"); + + let stale = + destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll"); + fs::write(&stale, "stale\n").expect("write stale library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝随包库里未声明的文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&stale).expect("remove stale library file"); + + let declared = destination_root + .join("agc-godot-editor") + .join(declared_relative_path(&format!( + "{}/{}", + staging.source_subdirectory, staging.files[0] + ))); + fs::remove_file(&declared).expect("remove declared library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝缺少声明文件的随包库"); + assert!(error.contains("缺少声明文件"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝随包库"); + assert!(error.contains("未声明文件"), "{error}"); + } + #[test] fn collect_sources_applies_declared_skip_rules() { let temp = tempfile::tempdir().expect("tempdir"); From 1dbbdcf329b9fed5207c2c91f8d964abff35eca0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 19:28:16 +0800 Subject: [PATCH 57/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=87=86=E5=A4=87=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在启动Vite前完成随包资源staging,避免Windows监听目录导致rename返回EPERM。 同步更新开发入口生命周期测试。 --- apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs | 5 +++-- apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 6c82e54cb..a5be013ac 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -152,6 +152,9 @@ async function runTauriDev( } try { + const devFeatures = readDevCargoFeatures(); + prepareResources(devFeatures); + // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, onChild(frontend) { @@ -163,8 +166,6 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; - const devFeatures = readDevCargoFeatures(); - prepareResources(devFeatures); const tauriArguments = buildTauriArguments( withDevCargoFeatures(argv, devFeatures), endpoint.url, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 5cfcd7d3f..1becc9303 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -181,8 +181,8 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(1); expect(order).toEqual([ 'preflight', - 'frontend-ready', 'resources', + 'frontend-ready', 'spawn', 'exit', 'cleanup', From 0bfc5768cc64956f0d18447ed81595d83a2ae491 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 20:24:23 +0800 Subject: [PATCH 58/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=B5=84=E6=BA=90=E6=9E=84=E5=BB=BA=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让Linux no-bundle smoke不再误用Windows随包资源目标。 统一dev与release的Cargo目标和feature解析,补充跨平台回归测试。 同步随包资源staging技术方案中的实际构建目标约定。 --- .../scripts/build-release.mjs | 69 +++-- .../scripts/build-release.test.mjs | 292 ++++++++++++++++++ .../scripts/cargo-features.mjs | 143 ++++++--- .../scripts/start-tauri-dev.mjs | 33 +- .../tests/start-tauri-dev.test.ts | 262 ++++++++++++++++ ...术方案】AGC随包资源staging归位-2026-09-26.md | 1 + 6 files changed, 720 insertions(+), 80 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 00f144de6..f007e8bd4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,7 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -20,7 +21,10 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; -import { prepareBundledResources } from './prepare-bundled-resources.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -42,26 +46,7 @@ function defaultTarget() { } function explicitBuildTarget(args) { - let target; - const separator = args.indexOf('--'); - const options = separator < 0 ? args : args.slice(0, separator); - for (let index = 0; index < options.length; index += 1) { - const argument = options[index]; - let value; - if (argument === '--target' || argument === '-t') { - value = options[++index]; - } else if (argument.startsWith('--target=')) { - value = argument.slice('--target='.length); - } else { - continue; - } - if (!value?.trim() || value.startsWith('-')) { - throw new Error('--target 缺少有效目标'); - } - if (target !== undefined) throw new Error('不能重复指定 --target'); - target = value.trim(); - } - return target; + return readCargoTarget(args); } function validateReleaseTarget(target) { @@ -494,6 +479,8 @@ export function runTauriBuild( spawn = spawnSync, stageRuntime = stageNodeRuntime, stageBundled = stageBundledResources, + platform = process.platform, + arch = process.arch, } = {}, ) { if ( @@ -502,19 +489,35 @@ export function runTauriBuild( ) { throw new Error('构建参数与发布上下文目标不一致'); } - const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel, target } = context; - const features = resolveEditorFeatures({ argv: args, target }); - // --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。 - if (!args.includes('--no-bundle')) { - stageRuntime(target); - } - stageBundled(target, { features }); - const configPath = writeChannelConfigFile( - channel, - target, - !args.includes('--no-bundle'), + const tauriArguments = buildTauriBuildArguments( + args, + context.target, + platform, ); + const bundling = !args.includes('--no-bundle'); + // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。 + const resourceTarget = + explicitBuildTarget(args) || + (bundling ? context.target : supportedHostTarget(platform, arch)); + const features = resolveEditorFeatures({ + argv: tauriArguments, + target: resourceTarget ?? platform, + env: {}, + }); + const { channel } = context; + if (bundling) { + stageRuntime(resourceTarget); + } + if (resourceTarget) { + stageBundled(resourceTarget, { features }); + } else { + console.log( + '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备', + ); + } + // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。 + const target = resourceTarget ?? context.target; + const configPath = writeChannelConfigFile(channel, target, bundling); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index e6438ed7f..4b6fc20cc 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -38,6 +38,7 @@ import { AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; +import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -1032,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for ); }); +const windowsEditorFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', +]; + +function uniqueSorted(values) { + return [...new Set(values ?? [])].sort(); +} + +function splitFeatureList(value) { + return String(value ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` +/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, +/// 随包资源准备必须拿到同一集合。 +function cargoFeaturesFromCommand(command) { + const argv = command.slice(command.indexOf('--') + 1); + const features = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = String(argv[index]); + if (argument.startsWith('--features=')) { + features.push(...splitFeatureList(argument.slice('--features='.length))); + } else if (argument === '--features' || argument === '-f') { + features.push(...splitFeatureList(argv[index + 1])); + index += 1; + } else if (/^-f.+/u.test(argument)) { + features.push(...splitFeatureList(argument.slice(2))); + } + } + return uniqueSorted(features); +} + +/** + * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, + * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, + * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), + * 还会出现「cargo 没开 feature、staging 开了」的错配。 + */ +test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + assert.equal(context.target, windowsTarget); + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform: 'linux', + arch: 'x64', + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); + assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); + assert.deepEqual( + cargoFeaturesFromCommand(command), + [], + 'Linux 宿主不得注入 Windows 编辑器 feature', + ); +}); + +test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + for (const [platform, arch, hostTarget, expectedFeatures] of [ + ['darwin', 'arm64', 'aarch64-apple-darwin', []], + ['darwin', 'x64', 'x86_64-apple-darwin', []], + ['win32', 'x64', windowsTarget, windowsEditorFeatures], + ]) { + const label = `${platform}/${arch}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal( + staged?.target, + hostTarget, + `${label} 必须按宿主三元组 staging`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与宿主不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + assert.ok( + !command.includes('--target'), + `${label} 宿主构建不得打显式 target`, + ); + } +}); + +test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { + for (const [platform, arch, target, expectedFeatures] of [ + ['linux', 'x64', windowsTarget, windowsEditorFeatures], + ['darwin', 'arm64', 'x86_64-apple-darwin', []], + ]) { + const args = ['--no-bundle', '--target', target]; + const context = resolveReleaseContext(args, {}); + const label = `${platform}/${arch} → ${target}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(args, context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(stageTarget, options) { + staged = { target: stageTarget, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); + assert.ok( + command.includes(target), + `${label} 必须把显式 target 传给 Tauri`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与预期不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + } +}); + +test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { + const crossArch = 'x86_64-apple-darwin'; + const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); + assert.equal(context.target, crossArch); + let runtimeTarget; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild([], context, { + platform: 'darwin', + arch: 'arm64', + stageRuntime(target) { + runtimeTarget = target; + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal(runtimeTarget, crossArch); + assert.equal( + staged?.target, + crossArch, + '打包构建必须按发布上下文目标 staging', + ); + assert.deepEqual(uniqueSorted(staged?.features), []); + assert.ok( + command.includes(crossArch), + '打包构建必须把发布上下文目标传给 Tauri', + ); + assert.deepEqual(cargoFeaturesFromCommand(command), []); +}); + +test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + const cases = [ + { + args: ['--no-bundle', '--features', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '--features=custom-a,custom-b'], + expected: ['custom-a', 'custom-b'], + }, + { + args: ['--no-bundle', '-f', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '-fcustom-editor-feature'], + expected: ['custom-editor-feature'], + }, + // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 + // (`tauri build --help`:Command line arguments passed to the runner), + // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 + { + args: ['--no-bundle', '--', '--features=app-runner'], + expected: [...windowsEditorFeatures, 'app-runner'], + }, + // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 + { + args: ['--no-bundle'], + env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, + }, + ]; + for (const entry of cases) { + const { args, expected } = entry; + const label = args.join(' '); + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { + runTauriBuild(args, context, { + platform: 'win32', + arch: 'x64', + stageRuntime() { + assert.fail(`${label} 不得准备 Node 运行时`); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, windowsTarget, label); + const cargoFeatures = cargoFeaturesFromCommand(command); + assert.deepEqual( + uniqueSorted(staged?.features), + cargoFeatures, + `${label}:staging feature 必须等于 Cargo 收到的 feature`, + ); + if (expected) { + assert.deepEqual( + cargoFeatures, + uniqueSorted(expected), + `${label}:Cargo feature 与预期不一致`, + ); + } + } +}); + +test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { + const hostTarget = supportedHostTarget(process.platform, process.arch); + const context = resolveReleaseContext(['--no-bundle'], {}); + const events = []; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + events.push(target); + }, + spawn() { + return { status: 0 }; + }, + }); + }); + assert.deepEqual(events, hostTarget ? [hostTarget] : []); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index c8303d42b..289b0c515 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -1,69 +1,134 @@ /** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */ export function withDefaultCargoFeatures(argv, features) { - const separator = argv.indexOf('--'); - const cargoArgs = separator < 0 ? argv : argv.slice(0, separator); - if ( - !features.length || - cargoArgs.some( - (value) => - value === '--features' || - value === '-f' || - value.startsWith('--features=') || - /^-f.+/u.test(value), - ) - ) { + // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的 + // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。 + if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) { return argv; } return [`--features=${features.join(',')}`, ...argv]; } +/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。 +function tauriOptions(argv) { + const separator = argv.indexOf('--'); + return separator < 0 ? argv : argv.slice(0, separator); +} + +/** + * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是 + * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。 + * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布 + * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。 + */ +function cargoArguments(argv) { + const separator = argv.indexOf('--'); + if (separator < 0) return argv; + const applicationStart = argv.indexOf('--', separator + 1); + return applicationStart < 0 ? argv : argv.slice(0, applicationStart); +} + +/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。 +function hasExplicitFeatures(value) { + return ( + value === '--features' || + value === '-f' || + value.startsWith('--features=') || + /^-f.+/u.test(value) + ); +} + +function parseFeatureNames(raw) { + return String(raw) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } -/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。 -/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。 -/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值, -/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。 +/** + * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。 + * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、 + * 取到另一个选项或重复指定都失败关闭。 + * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标 + * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。 + */ +export function readCargoTarget(argv = []) { + const options = cargoArguments(argv); + let target; + for (let index = 0; index < options.length; index += 1) { + const argument = String(options[index]); + let value; + if (argument === '--target' || argument === '-t') { + value = options[index + 1]; + index += 1; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else if (argument.startsWith('-t') && argument.length > 2) { + value = argument.slice(2).replace(/^=/u, ''); + } else { + continue; + } + if (!value?.trim() || String(value).startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = String(value).trim(); + } + return target; +} + +/** + * 单一声明式解析:本次构建实际生效的编辑器 feature 集。 + * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖) + * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature) + * > 目标平台默认值。 + * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源 + * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。 + */ export function resolveEditorFeatures({ argv = [], target, env = process.env, } = {}) { - // 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。 - const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim(); - if (fromEnv) { - return fromEnv - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } const fromArgv = featuresFromArgv(argv); if (fromArgv) { return fromArgv; } + // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。 + if (env.AGC_DEV_CARGO_FEATURES !== undefined) { + return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES); + } return defaultEditorFeatures(target); } -/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。 +/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并); +/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。 export function featuresFromArgv(argv = []) { - for (let index = 0; index < argv.length; index += 1) { - const value = String(argv[index]); + const options = cargoArguments(argv); + const features = []; + let found = false; + for (let index = 0; index < options.length; index += 1) { + const value = String(options[index]); if (value.startsWith('--features=')) { - return value - .slice('--features='.length) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } - if (value === '--features' && argv[index + 1] !== undefined) { - return String(argv[index + 1]) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); + found = true; + features.push(...parseFeatureNames(value.slice('--features='.length))); + } else if (value === '--features' || value === '-f') { + found = true; + index += 1; + const next = options[index]; + // 变参 flag:后面不是选项时才是它的取值。 + if (next !== undefined && !String(next).startsWith('-')) { + features.push(...parseFeatureNames(next)); + } + } else if (value.startsWith('-f') && value.length > 2) { + found = true; + features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, ''))); } } - return null; + return found ? features : null; } diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index a5be013ac..7cc06d82a 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -56,9 +57,14 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { } // 开发和发行构建使用同一平台编辑器 feature 集合。 -// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭; +// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。 +// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。 function readDevCargoFeatures(argv = []) { - return resolveEditorFeatures({ argv, target: process.platform }); + return resolveEditorFeatures({ + argv, + target: readCargoTarget(argv) ?? process.platform, + }); } function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { @@ -67,11 +73,19 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { /// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 /// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源: +/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过, +/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。 function prepareBundledResourcesBeforeTauri( - features = readDevCargoFeatures(), - { prepare = prepareBundledResources, log = console.log } = {}, + features, + argv = [], + { + prepare = prepareBundledResources, + log = console.log, + hostTarget = supportedHostTarget(), + } = {}, ) { - const target = supportedHostTarget(); + const target = readCargoTarget(argv) ?? hostTarget; if (!target) { log( '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', @@ -80,7 +94,7 @@ function prepareBundledResourcesBeforeTauri( } const summaries = prepare({ target, - features: new Set(features), + features: new Set(features ?? readDevCargoFeatures(argv)), log: (line) => log(`[ai-game-creator-shell] ${line}`), }); for (const summary of summaries) { @@ -152,8 +166,11 @@ async function runTauriDev( } try { - const devFeatures = readDevCargoFeatures(); - prepareResources(devFeatures); + // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数, + // 不能参与解析;注入默认 feature 也不改变这份解析结果。 + const devTauriArguments = buildTauriArguments(argv, endpoint.url); + const devFeatures = readDevCargoFeatures(devTauriArguments); + prepareResources(devFeatures, devTauriArguments); // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 1becc9303..b3de87823 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -5,6 +5,10 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; +import { + readCargoTarget, + resolveEditorFeatures, +} from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, spawnChild, @@ -13,6 +17,7 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, + prepareBundledResourcesBeforeTauri, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -363,3 +368,260 @@ describe('AI 游戏创作 Tauri dev 进程环境', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { + const windowsFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ]; + // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。 + const devConfig = + '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}'; + + test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => { + const calls: [string, string[]][] = []; + const hostTarget = 'aarch64-apple-darwin'; + const prepare = (options: { + target: string; + features: Iterable; + }) => { + calls.push([options.target, [...options.features]]); + return []; + }; + + for (const argv of [ + ['dev', '--config', devConfig], + ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig], + ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig], + // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。 + [ + 'dev', + '--config', + devConfig, + '--', + '--', + '--target=x86_64-pc-windows-msvc', + ], + ]) { + prepareBundledResourcesBeforeTauri(windowsFeatures, argv, { + hostTarget, + prepare, + log: () => {}, + }); + } + + expect(calls.map(([target]) => target)).toEqual([ + hostTarget, + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + hostTarget, + ]); + expect(calls[0][1]).toEqual(windowsFeatures); + }); + + test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { + const prepare = vi.fn(); + const log = vi.fn(); + + prepareBundledResourcesBeforeTauri( + windowsFeatures, + ['dev', '--config', devConfig], + { hostTarget: null, prepare, log }, + ); + + expect(prepare).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备'); + }); + + async function runCapturingDev(argv: string[]) { + const prepared: unknown[][] = []; + const spawned: string[][] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 0, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev(argv, { + resolveDevEndpoint: resolveTestEndpoint, + preflight: async () => {}, + prepareResources: (...args: unknown[]) => { + prepared.push(args); + }, + spawnCli: (args: string[]) => { + spawned.push(args); + return child; + }, + waitForCli: async () => ({ type: 'exit', code: 0, signal: null }), + terminateTree: async () => ({ stopped: true, forced: false }), + }); + return { result, prepared, spawned }; + } + + test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '-f', + 'custom-feature', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([ + ['custom-feature'], + ]); + expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]).not.toContain( + `--features=${windowsFeatures.join(',')}`, + ); + // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--target', + 'x86_64-apple-darwin', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(prepared[0][1]).toEqual([ + 'dev', + '--target', + 'x86_64-apple-darwin', + '--config', + devConfig, + ]); + expect(spawned[0]).toEqual(prepared[0][1]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--', + '--features=app-tool', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(spawned[0]).toEqual([ + 'dev', + '--config', + devConfig, + '--', + '--', + '--features=app-tool', + ]); + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect( + spawned[0].some((argument) => argument.startsWith('--features')), + ).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('环境变量只在不与显式 CLI 冲突时生效', () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + expect( + resolveEditorFeatures({ + argv: ['dev', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['template-library-fixtures']); + expect( + resolveEditorFeatures({ + argv: ['dev', '-f', 'cli-feature', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['cli-feature']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => { + test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => { + expect(readCargoTarget([])).toBeUndefined(); + expect(readCargoTarget(['--no-watch'])).toBeUndefined(); + expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。 + expect( + readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']), + ).toBe('aarch64-apple-darwin'); + // 第二个 `--` 之后是应用参数。 + expect( + readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']), + ).toBeUndefined(); + expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标'); + expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow( + '缺少有效目标', + ); + expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow( + '不能重复指定', + ); + }); + + test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => { + const cases: [string[], string[]][] = [ + [ + ['-f', 'a,b'], + ['a', 'b'], + ], + [['-fa'], ['a']], + [['--features=explicit'], ['explicit']], + // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。 + [ + ['--features', 'a', '-f', 'b'], + ['a', 'b'], + ], + // 显式给空集合即关闭默认,而不是回落默认值。 + [['--features='], []], + // 第二个 `--` 之后是应用参数。 + [ + ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'], + [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ], + ], + ]; + + for (const [argv, expected] of cases) { + expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual( + expected, + ); + } + }); +}); diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index a72d6d1fa..6a6f157bd 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -102,6 +102,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 +9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。 ### 4.4 build.rs 退化后的职责 From e7fa90ae18323a3d4824952b1ad5f4694825e9be Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 11:35:27 +0800 Subject: [PATCH 59/60] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=A0=A1=E9=AA=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复变参 feature 解析、Windows Codex 缓存幂等性、插件缓存产物检查和插件根目录链接校验。 补齐 staging/backup 忽略规则与 feature 回归测试。 --- .gitignore | 4 +++ .../scripts/build-release.mjs | 9 +++++++ .../scripts/cargo-features.mjs | 13 +++++----- .../scripts/cargo-features.test.mjs | 18 ++++++++++++- .../scripts/prepare-bundled-resources.mjs | 26 ++++++++++++++++--- .../src-tauri/build_support/package_layout.rs | 11 ++++++-- 6 files changed, 68 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index e4b2363a4..652820e39 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,10 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ /apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index f007e8bd4..bfb958806 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -499,6 +499,15 @@ export function runTauriBuild( const resourceTarget = explicitBuildTarget(args) || (bundling ? context.target : supportedHostTarget(platform, arch)); + if ( + !bundling && + !resourceTarget && + defaultEditorFeatures(platform).length > 0 + ) { + throw new Error( + `当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`, + ); + } const features = resolveEditorFeatures({ argv: tauriArguments, target: resourceTarget ?? platform, diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 289b0c515..588912ac1 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -39,7 +39,7 @@ function hasExplicitFeatures(value) { function parseFeatureNames(raw) { return String(raw) - .split(',') + .split(/[\s,]+/u) .map((name) => name.trim()) .filter(Boolean); } @@ -119,11 +119,12 @@ export function featuresFromArgv(argv = []) { features.push(...parseFeatureNames(value.slice('--features='.length))); } else if (value === '--features' || value === '-f') { found = true; - index += 1; - const next = options[index]; - // 变参 flag:后面不是选项时才是它的取值。 - if (next !== undefined && !String(next).startsWith('-')) { - features.push(...parseFeatureNames(next)); + while ( + index + 1 < options.length && + !String(options[index + 1]).startsWith('-') + ) { + index += 1; + features.push(...parseFeatureNames(options[index])); } } else if (value.startsWith('-f') && value.length > 2) { found = true; diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 3358524b9..baf462966 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { buildTauriBuildArguments } from './build-release.mjs'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + featuresFromArgv, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; test('Windows release includes the same editor feature as development', () => { assert.deepEqual( @@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', () ['--features=cocos-editor-execute', '--', '--features=app'], ); }); + +test('features 变参会完整消费空格与逗号分隔的值', () => { + assert.deepEqual( + featuresFromArgv([ + '-f', + 'cocos-editor-execute,unity-editor-execute', + 'godot-editor-execute', + '--target', + 'x86_64-pc-windows-msvc', + ]), + ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'], + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index f22e34c00..1b25f2585 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -374,7 +374,11 @@ function unitMatchesExisting(unitPath, declaration, unit, desired) { if (info.size !== file.size || sha256File(filePath) !== file.sha256) { return false; } - if (relative === member.layout.executable && (info.mode & 0o111) === 0) { + if ( + process.platform !== 'win32' && + relative === member.layout.executable && + (info.mode & 0o111) === 0 + ) { return false; } } @@ -1084,6 +1088,10 @@ function pluginTreeMatches( sourceRoot, '插件资源', )) { + const staged = path.join(stagedDirectory, relative); + if (!existsSync(staged)) { + return false; + } allowedFiles.add(`${relativePrefix}${relative}`); } continue; @@ -1117,6 +1125,14 @@ function pluginTreeMatches( libraryStagingEnabled(staging, target, features) ) { for (const relative of staging.files) { + const staged = path.join( + pluginDestination, + staging.sourceSubdirectory, + relative, + ); + if (!existsSync(staged)) { + return false; + } allowedFiles.add( `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, ); @@ -1128,9 +1144,11 @@ function pluginTreeMatches( plugin.name === payload.plugin && nativePayloadEnabled(payload, target, features) ) { - allowedFiles.add( - `${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`, - ); + const relative = `${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`; + if (!existsSync(path.join(pluginDestination, relative))) { + return false; + } + allowedFiles.add(`${plugin.name}/${relative}`); } } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index b334fe265..ddd54ee55 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -308,12 +308,19 @@ pub fn validate_staged_plugins( if !plugin_staging_applies(target) { return Ok(()); } - if !destination_root.is_dir() { + let metadata = std::fs::symlink_metadata(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(format!( - "插件随包资源目录缺失:{}", + "插件随包资源目录缺失或不允许符号链接:{}", destination_root.display() )); } + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; let declared_names = declared_plugins .iter() From a344686b59fb21a250b63ece5408de22dfe6db8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Thu, 1 Oct 2026 11:51:10 +0800 Subject: [PATCH 60/60] =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=B7=A5=E7=A8=8B=E6=96=B0=E5=A2=9E=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=90=8C=E6=AD=A5=E6=97=B6=E9=97=B4=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示 复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取 为全量读取增加超时、数量和游标边界,失败时保留原结果 补充三项关键交互测试并同步技术方案、运维说明与共享记忆 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/546 --- .../pages/AdminProjectSnapshotsPage.test.tsx | 240 +++++++++++++++++- .../src/pages/AdminProjectSnapshotsPage.tsx | 170 ++++++++++++- .../shared-memory/decision-log.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 5 files changed, 412 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 7916e60e3..1e185e9a5 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -16,7 +16,10 @@ import { getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; -import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import type { + AdminProjectSnapshotEntry, + AdminProjectSnapshotListResponse, +} from '../api/adminApiTypes'; import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; vi.mock('../api/adminApiClient', async () => ({ @@ -58,6 +61,241 @@ afterEach(() => { vi.useRealTimers(); }); +test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => { + const olderProjects = Array.from({ length: 22 }, (_, index) => ({ + ...entry, + projectId: `older-${index}`, + projectName: `旧项目 ${index}`, + syncedAtMs: entry.syncedAtMs + index, + })); + const tiedProjects = [ + { ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' }, + { ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' }, + { ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' }, + ].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' }) + // 后端有界扫描可能返回带下一游标的空页,不能提前结束。 + .mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' }) + .mockResolvedValueOnce({ + items: [ + ...tiedProjects, + { + ...olderProjects[0]!, + projectName: '更新项目', + syncedAtMs: entry.syncedAtMs + 200, + }, + ], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByRole('button', { name: '恢复默认顺序' }); + const rowNames = () => + screen + .getAllByRole('row') + .map((row) => row.querySelector('td strong')?.textContent) + .filter(Boolean); + expect(rowNames().slice(0, 4)).toEqual([ + '更新项目', + '同秒甲一', + '同秒甲二', + '同秒乙', + ]); + expect(rowNames()).toHaveLength(20); + expect(screen.getByRole('navigation').textContent).toContain('共 25 个'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: 'scan-3', limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(rowNames()).toHaveLength(5); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(rowNames()[0]).toBe('更新项目'); + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + expect(rowNames()).toHaveLength(25); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '刷新后的项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新后的项目'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: null, limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 6, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => { + const projects = Array.from({ length: 45 }, (_, index) => ({ + ...entry, + projectId: `sorted-${index}`, + projectName: `排序项目 ${index}`, + syncedAtMs: entry.syncedAtMs - index, + })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ items: projects, nextCursor: null }) + .mockRejectedValueOnce(new Error('恢复失败')) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第一页' }], + nextCursor: 'default-next', + }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第二页' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('排序项目 0'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('恢复失败'); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(screen.getByRole('navigation').textContent).toContain('共 45 个'); + expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(screen.getByText('排序项目 40')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3); + + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('默认第一页'); + expect(screen.getByRole('navigation').textContent).toContain('第 1 页'); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('默认第二页'); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: 'default-next', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '部分读取项目' }], + nextCursor: 'scan-next', + }) + .mockRejectedValueOnce(new Error('全量读取失败')); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('全量读取失败'); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('部分读取项目')).toBeNull(); + expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull(); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '完整排序项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('完整排序项目'); + vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce( + new Error('刷新读取失败'), + ); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新读取失败'); + expect(screen.getByText('完整排序项目')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('共 1 个'); +}); + +test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'dev', + channels: ['dev', 'release'], + }); + let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void; + let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void; + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancelled = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldChannel = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[1]![2]!; + fireEvent.click(screen.getByRole('button', { name: '取消排序' })); + expect(cancelledSignal.aborted).toBe(true); + await act(async () => { + finishCancelled({ + items: [{ ...entry, projectName: '已取消项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('已取消项目')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[2]![2]!; + fireEvent.change(screen.getByLabelText('项目工程渠道'), { + target: { value: 'release' }, + }); + await screen.findByText('正式渠道项目'); + expect(oldChannelSignal.aborted).toBe(true); + await act(async () => { + finishOldChannel({ + items: [{ ...entry, projectName: '旧渠道项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('正式渠道项目')).toBeTruthy(); + expect(screen.queryByText('旧渠道项目')).toBeNull(); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); +}); + test('按项目展示完整性并限制未完成工程下载', async () => { vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ items: [ diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 026adec6b..b8c6d7b3a 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -10,7 +10,7 @@ import { AdminPagination, AdminStatusPill, } from '@genarrative/shared/components'; -import { Download, RefreshCcw, X } from 'lucide-react'; +import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -50,6 +50,9 @@ const snapshotStatuses: Record< const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZE_OPTIONS = [20, 50, 100]; +const MAX_SORT_PAGES = 200; +const MAX_SORT_PROJECTS = 10_000; +const SORT_TIMEOUT_MS = 60_000; export function AdminProjectSnapshotsPage({ token, @@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({ const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); + const [isSorted, setIsSorted] = useState(false); + const [isSorting, setIsSorting] = useState(false); + const [sortProgress, setSortProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); @@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({ // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 const pageCursors = useRef<(string | null)[]>([null]); + // 全量读取完成后才发布;分页只切片本次读到的完整集合。 + const sortedItems = useRef(null); const loadPage = useCallback( - async (cursor: string | null, limit: number, page: number) => { + async ( + cursor: string | null, + limit: number, + page: number, + restoreDefault = false, + ) => { + if (!restoreDefault && sortedItems.current !== null) { + setItems(sortedItems.current.slice((page - 1) * limit, page * limit)); + setPageIndex(page); + setHasLoaded(true); + setErrorMessage(''); + return; + } listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({ controller.signal, ); if (controller.signal.aborted) return; + // 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。 + if (restoreDefault) { + sortedItems.current = null; + setIsSorted(false); + pageCursors.current = [null]; + } setItems(response.items); setNextCursor(response.nextCursor); setPageIndex(page); @@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({ return () => controller.abort(); }, [token, onUnauthorized]); + useEffect(() => { + sortedItems.current = null; + setIsSorted(false); + }, [token, channel]); + useEffect(() => { if (channel === null) return undefined; + setIsSorting(false); + setIsLoading(false); + setSortProgress(0); pageCursors.current = [null]; setItems([]); setNextCursor(null); @@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({ }, [loadPage, pageSize, channel]); function goToNextPage() { + if (sortedItems.current !== null) { + void loadPage(null, pageSize, pageIndex + 1); + return; + } if (!nextCursor) return; pageCursors.current[pageIndex] = nextCursor; void loadPage(nextCursor, pageSize, pageIndex + 1); @@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({ } function refreshCurrentPage() { + if (sortedItems.current !== null) { + void sortAllProjects(); + return; + } void loadPage( pageCursors.current[pageIndex - 1] ?? null, pageSize, @@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({ ); } + async function sortAllProjects() { + if (channel === null) return; + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setIsSorting(true); + setSortProgress(0); + setErrorMessage(''); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, SORT_TIMEOUT_MS); + try { + const projects = new Map(); + const seenCursors = new Set(); + let cursor: string | null = null; + for (let page = 0; page < MAX_SORT_PAGES; page += 1) { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 100, channel }, + controller.signal, + ); + if (controller.signal.aborted) return; + for (const project of response.items) { + const key = snapshotKey(project); + const previous = projects.get(key); + if (!previous || project.syncedAtMs >= previous.syncedAtMs) { + projects.set(key, project); + } + } + if (projects.size > MAX_SORT_PROJECTS) { + throw new Error('项目超过 10,000 个,无法完成全量排序'); + } + setSortProgress(projects.size); + if (!response.nextCursor) { + const result = [...projects.values()].sort( + (left, right) => + right.syncedAtMs - left.syncedAtMs || + compareIds(left.userId, right.userId) || + compareIds(left.projectId, right.projectId), + ); + sortedItems.current = result; + setIsSorted(true); + setItems(result.slice(0, pageSize)); + setPageIndex(1); + setNextCursor(null); + setHasLoaded(true); + return; + } + if (seenCursors.has(response.nextCursor)) { + throw new Error('项目列表游标重复,无法完成全量排序,请重试'); + } + seenCursors.add(response.nextCursor); + cursor = response.nextCursor; + } + throw new Error('项目读取超过 200 页,无法完成全量排序'); + } catch (error: unknown) { + if (!controller.signal.aborted) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } finally { + clearTimeout(timeout); + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + setIsSorting(false); + if (timedOut) setErrorMessage('全量项目读取超时,请重试'); + } + } + } + + function cancelSorting() { + listController.current?.abort(); + listController.current = null; + setIsLoading(false); + setIsSorting(false); + } + + function restoreDefaultOrder() { + void loadPage(null, pageSize, 1, true); + } + async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; const controller = new AbortController(); @@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({ ))} + void sortAllProjects() + } + > + + {isSorting ? ( + <> + + 正在读取全部项目,已读取 {sortProgress} 个 + + + + + ) : null} 第 {pageIndex} 页 {items.length ? `,本页 ${items.length} 个项目` : ''} + {isSorted + ? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧` + : ''} } pageSize={{ @@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({ }} busy={isLoading} previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }} - next={{ disabled: !nextCursor, onClick: goToNextPage }} + next={{ + disabled: isSorted + ? pageIndex * pageSize >= (sortedItems.current?.length ?? 0) + : !nextCursor, + onClick: goToNextPage, + }} /> ) : null } @@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } +function compareIds(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function formatBytes(bytes: number) { const units = ['B', 'KiB', 'MiB', 'GiB']; const unit = Math.min( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c01d2a426..6a40e2722 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9258,7 +9258,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。 - 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。 - 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。 -- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。 +- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。 - 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。 - 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。 - 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f65a82ef2..21b0ba771 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1787,6 +1787,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。 - 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。 - 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。 +- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 +- 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。 +- 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。 - `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 - `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。 - ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7946645c1..ee2e9c2e2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -620,6 +620,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。 + 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。