Compare commits

..

6 Commits

Author SHA1 Message Date
lhk229 fc439612b0 Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m12s
Project CI / Backend tests (pull_request) Successful in 3m57s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m2s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m46s
2026-09-25 01:25:44 +08:00
lhk229 6ed1fd26ed 修复 Linux 沙箱命令退出回收的偶发竞态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m57s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m14s
Project CI / Repository checks (push) Successful in 2m2s
Project CI / AI game creator shell web tests (push) Successful in 1m30s
统一正常退出、取消和超时的有界进程组退出确认,保留归属校验
提前记录启动身份,修补终态协议错误的清理及输出任务回收
补充确定性竞态回归并同步运行时规范和排障记忆
保持现有 CI 并行、分片和重试策略不变
2026-09-24 16:45:07 +00:00
lhk229 2370dc509b 修复账号场景生成的幂等与结果返回协议
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑
场景配方保留 AGC 来源标记,恢复可下载的队列完成结果
补充非法幂等键、场景结果序列化和幂等命名空间回归测试
同步 OpenAPI、技术方案、实施计划及排障记录
2026-09-24 16:18:40 +00:00
lhk229 4912aa4df0 删除创作首页登录送泥点营销文案
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Frontend tests (pull_request) Successful in 2m0s
Project CI / Native shell tests (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m17s
删除 CreationLandingView hero 区“登录即送 100 泥点,可以免费制作 50 个素材”文案
删除 CreationLandingView.test.tsx 中对应的文案断言
清理 index.css 中已无调用方的 creation-landing__hero-benefit 样式
更正 decision-log 中已过期的“注册赠送固定为 100 泥点”决策记录,注明金额由线上钱包配置决定
2026-09-24 16:08:42 +00:00
lhk229 bb69e09b8d Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m6s
Project CI / Repository checks (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m32s
2026-09-24 23:47:34 +08:00
lhk229 571cb00fe4 新增 external v1 游戏场景生成路由并迁移 AGC 美术包背景阶段
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
- api-server 新增 POST /api/external/v1/editor/scenes/generations,复用 editor:image-generate scope、幂等键和站内场景生图队列
- editor_project.rs 抽取站内与外部共用的场景生图 payload 构造函数,站内 handler 改为调用共享函数
- 同步更新 external v1 OpenAPI 契约与 MCP 派生排除标记,登记 docs/README.md 索引
- AGC 客户端美术包背景阶段改走新场景路由,stylePreset 固定 custom + customStyle 保留现有风格文案
- external_generation_state 快照与 recovery_scan 账本白名单支持新场景路由
- direct_runtime 背景资源识别同时兼容新场景路由与旧通用路由,新增旧路由回放测试
- 更新 genarrative-external-editor-api skill 参考文档
- 新增主规范、里程碑规范与实施计划三份 SDD 文档
2026-09-24 15:40:30 +00:00
34 changed files with 893 additions and 1360 deletions
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` | | `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` | | `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` | | `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` | | `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` | | `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` | | `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Capability | POST path | Required body fields | Common optional body fields | | Capability | POST path | Required body fields | Common optional body fields |
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all nine through: Poll all ten through:
```text ```text
GET /api/external/v1/generations/{operationId} GET /api/external/v1/generations/{operationId}
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
Use OpenAPI as the final authority; these common values are a routing aid: Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. - Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. - Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`. - Image `imageSize`: `0.5K`, `1K`, `2K`.
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
## Art Spec and Image Request ## Art Spec and Image Request
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt. Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation: When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
+9 -21
View File
@@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject) ## 项目开发对话(DirectProject)
**DirectProject 专属聊天模块**: **DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 _Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**: **项目工作台布局**:
@@ -208,32 +208,20 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运
_Avoid_: 进度通知、快照轮询、第二套历史 _Avoid_: 进度通知、快照轮询、第二套历史
**逻辑回合**: **逻辑回合**:
Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
_Avoid_: Codex 原生回合、原生日志、进程生命周期 _Avoid_: Codex 原生回合、原生日志、进程生命周期
**待发消息队列**: **接单**:
Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。 把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录 _Avoid_: 发送成功、命令调用、接口返回
**待发消息**: **拒单**:
已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。 接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
_Avoid_: 回合、在途回合、草稿
**入队**:
把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。
_Avoid_: 发送成功、已经开跑、回合成立
**入队失败**:
入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。
_Avoid_: 回合失败、执行失败、失败事件 _Avoid_: 回合失败、执行失败、失败事件
**放行**:
Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。
_Avoid_: 前端放行、定时轮询、放行失败
**在途回合**: **在途回合**:
界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。 界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
_Avoid_: 运行中回合、乐观锁、前端发送队列 _Avoid_: 运行中回合、乐观锁、发送队列
**聊天投影**: **聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
@@ -29,7 +29,6 @@ mod direct_project_history;
mod direct_project_turn_history; mod direct_project_turn_history;
mod direct_runtime; mod direct_runtime;
mod direct_thread_manager; mod direct_thread_manager;
mod direct_thread_queue;
mod direct_thread_wire; mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
@@ -66,7 +65,6 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*; pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*; pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*; pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_queue::*;
pub(crate) use direct_thread_wire::*; pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
/// DirectProject 本轮 user input 的唯一结构化入口。 /// DirectProject 本轮 user input 的唯一结构化入口。
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type", deny_unknown_fields)] #[serde(tag = "type", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserItem { pub(crate) enum DirectCodexUserItem {
@@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem {
Message(DirectCodexUserMessageItem), Message(DirectCodexUserMessageItem),
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserMessageItem { pub(crate) struct DirectCodexUserMessageItem {
@@ -19,14 +19,14 @@ pub(crate) struct DirectCodexUserMessageItem {
pub(crate) id: String, pub(crate) id: String,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserRole { pub(crate) enum DirectCodexUserRole {
User, User,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[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/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserContentPart { pub(crate) enum DirectCodexUserContentPart {
@@ -43,7 +43,7 @@ pub(crate) enum DirectCodexUserContentPart {
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) struct DirectCodexUserAttachmentReferencePart {
@@ -55,7 +55,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) status: String, pub(crate) status: String,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) struct DirectCodexUserRuntimeRegionPart {
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
.unwrap_or(false) .unwrap_or(false)
} }
fn direct_taonier_background_asset_identity(
root: &Path,
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
) -> Option<DirectTaonierArtAssetIdentity> {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/scenes/generations",
"scene",
expected_reference_source,
)
.or_else(|| {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
expected_reference_source,
)
})
}
fn direct_taonier_art_base_is_valid(root: &Path) -> bool { fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity( let Some(art_spec) = direct_taonier_art_asset_identity(
root, root,
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
) else { ) else {
return false; return false;
}; };
let Some(background) = direct_taonier_art_asset_identity( let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
) else {
return false; return false;
}; };
let _ = (background, art_spec); let _ = (background, art_spec);
@@ -3325,15 +3342,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
}) })
.flatten(); .flatten();
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
direct_taonier_art_asset_identity( direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(art_spec),
)
.is_some()
}); });
let art_spec = match existing_art_spec { let art_spec = match existing_art_spec {
Some(identity) => identity, Some(identity) => identity,
@@ -3409,15 +3418,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
} }
}; };
if mode.regenerates_existing() if mode.regenerates_existing()
|| direct_taonier_art_asset_identity( || direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
)
.is_none()
{ {
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图"); emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
let outcome = match generate_direct_taonier_art_asset_at( let outcome = match generate_direct_taonier_art_asset_at(
@@ -8294,8 +8295,8 @@ mod tests {
vec!["taonier-resource-icon-spec".to_string()], vec!["taonier-resource-icon-spec".to_string()],
), ),
GameCreationAppAssetKind::Scene => ( GameCreationAppAssetKind::Scene => (
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/scenes/generations",
"spec", "scene",
vec!["taonier-resource-icon-spec".to_string()], vec!["taonier-resource-icon-spec".to_string()],
), ),
_ => ( _ => (
@@ -8679,6 +8680,46 @@ mod tests {
); );
} }
#[test]
fn legacy_route_background_registration_still_counts_as_valid_base() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记")
.expect("init project");
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
register_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
GameCreationAppAssetKind::IconSpec,
);
std::fs::write(
root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH),
tiny_visible_png(),
)
.expect("background bytes");
register_local_asset_at(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"image/png",
"platform-art",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("taonier-project".to_string()),
resource_id: Some("taonier-resource-game-background".to_string()),
asset_object_id: Some("taonier-object-game-background".to_string()),
task_id: Some("taonier-task-game-background".to_string()),
prompt: None,
model: Some("gpt-image-2".to_string()),
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()],
},
)
.expect("register legacy-route background");
assert!(direct_taonier_art_base_is_valid(root.path()));
}
fn tiny_visible_png() -> Vec<u8> { fn tiny_visible_png() -> Vec<u8> {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
File diff suppressed because it is too large Load Diff
@@ -1,200 +0,0 @@
//! 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<String>,
/// 入队那一刻的宿主毫秒钟。
pub(crate) at: u64,
}
impl PendingDirectTurn {
/// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。
///
/// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里
/// (canonical 形状与 prompt 都是)。
pub(crate) fn prepare(
client_turn_id: String,
user_item: DirectCodexUserItem,
prompt: String,
creation_type: Option<String>,
at: u64,
) -> Result<Self, serde_json::Error> {
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<String> {
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)
);
}
}
@@ -13,7 +13,6 @@
use crate::agent::redact_secret_tokens; use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context; use crate::agent::sanitize_error_context;
use crate::agent::DirectCodexUserItem;
use crate::agent::DirectTurnFailureKind; use crate::agent::DirectTurnFailureKind;
use crate::redact_absolute_path_tokens; use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -213,33 +212,6 @@ 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"` 时必有,其余终态没有)。 /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
/// ///
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 /// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
@@ -287,7 +259,7 @@ impl DirectTurnFailure {
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[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/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadEvent { pub(crate) enum DirectThreadEvent {
@@ -349,34 +321,6 @@ pub(crate) enum DirectThreadEvent {
#[serde(default)] #[serde(default)]
request_id: Option<String>, request_id: Option<String>,
}, },
/// 待发消息入队:数组顺序就是队首到队尾的顺序。
///
/// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者
/// 靠这一点在 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<String>,
/// 入队那一刻的宿主毫秒钟。
#[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<f64>")]
at: Option<u64>,
},
} }
impl DirectThreadEvent { impl DirectThreadEvent {
@@ -473,51 +417,6 @@ impl DirectThreadEvent {
Self::Request { kind, request_id } Self::Request { kind, request_id }
} }
/// 待发消息入队事件。
pub(crate) fn queue_enqueued(
client_turn_id: String,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
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<DirectQueueRemovalReason> {
match self {
Self::QueueRemoved { reason, .. } => Some(*reason),
_ => None,
}
}
/// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。
/// ///
/// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。
@@ -528,8 +427,6 @@ impl DirectThreadEvent {
| Self::TurnCompleted { at, .. } | Self::TurnCompleted { at, .. }
| Self::ItemStarted { at, .. } | Self::ItemStarted { at, .. }
| Self::ItemCompleted { at, .. } => *at, | Self::ItemCompleted { at, .. } => *at,
Self::QueueEnqueued { at, .. } => Some(*at),
Self::QueueRemoved { at, .. } => *at,
Self::ItemDelta { .. } | Self::Request { .. } => None, Self::ItemDelta { .. } | Self::Request { .. } => None,
} }
} }
@@ -559,7 +456,7 @@ impl DirectThreadEvent {
} }
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadSubscriptionBootstrap { pub(crate) struct DirectThreadSubscriptionBootstrap {
@@ -571,7 +468,7 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
pub(crate) events: Vec<DirectThreadEvent>, pub(crate) events: Vec<DirectThreadEvent>,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadConsumeResult { pub(crate) struct DirectThreadConsumeResult {
@@ -1569,61 +1466,4 @@ mod tests {
.expect("failed turn without failure payload"); .expect("failed turn without failure payload");
assert_eq!(sparse.failure(), None); 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::<DirectThreadEvent>(
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::<DirectThreadEvent>(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",
})
);
}
} }
@@ -2835,14 +2835,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
&& art_spec_asset_type == Some("icon-spec") && art_spec_asset_type == Some("icon-spec")
} }
GameCreationAppAssetKind::Scene => { GameCreationAppAssetKind::Scene => {
snapshot.endpoint == "/api/external/v1/editor/images/generations" snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
&& snapshot.generation_kind == "spec" && snapshot.generation_kind == "scene"
&& platform_art_runtime_references_match_request_contract( && platform_art_runtime_references_match_request_contract(
&snapshot.reference_resource_ids, &snapshot.reference_resource_ids,
GameCreationAppAssetKind::Scene, GameCreationAppAssetKind::Scene,
) )
&& request_asset_kind.as_deref() == Some("game-background") && json_string_field(&request_body, "sceneContent")
&& art_spec_asset_type == Some("background") .is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
} }
GameCreationAppAssetKind::IconSpritesheet => { GameCreationAppAssetKind::IconSpritesheet => {
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations"
@@ -3248,6 +3249,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
GameCreationAppAssetKind::Image => "image", GameCreationAppAssetKind::Image => "image",
GameCreationAppAssetKind::Character => "character", GameCreationAppAssetKind::Character => "character",
GameCreationAppAssetKind::PublicationMaterial => "publication-material", GameCreationAppAssetKind::PublicationMaterial => "publication-material",
GameCreationAppAssetKind::Scene => "scene",
_ => "spec", _ => "spec",
}; };
let is_canonical_art_spritesheet = let is_canonical_art_spritesheet =
@@ -3291,6 +3293,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
}, },
}), }),
) )
} else if options.asset_kind == GameCreationAppAssetKind::Scene {
(
"/api/external/v1/editor/scenes/generations",
serde_json::json!({
"sceneContent": generation_prompt,
"stylePreset": "custom",
"customStyle": prompt_text!("media.scene_style"),
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"referenceImageSrcs": references.ordered.clone(),
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}),
)
} else { } else {
( (
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/images/generations",
@@ -13272,14 +13293,13 @@ mod canvas_generation_tests {
let background = write_retained_stage( let background = write_retained_stage(
root, root,
"run-background-canonical-and-user-references", "run-background-canonical-and-user-references",
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/scenes/generations",
serde_json::json!({ serde_json::json!({
"prompt": "保留账本参考合同", "sceneContent": "保留账本参考合同",
"kind": "spec", "stylePreset": "custom",
"assetKind": "game-background", "customStyle": "原创横屏 Web 游戏场景背景",
"projectId": "test-canvas-project", "projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder", "assetFolderId": "test-asset-folder",
"generationInputs": { "artSpec": { "assetType": "background" } },
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
}), }),
); );
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
(generation_kind, reference_resource_ids) (generation_kind, reference_resource_ids)
} }
"/api/external/v1/editor/scenes/generations" => {
if json_string_field(&request_body, "sceneContent")
.is_none_or(|value| value.trim().is_empty())
{
return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string());
}
let reference_resource_ids = request_body
.get("referenceImageSrcs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
"External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string()
})?
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
("scene".to_string(), reference_resource_ids)
}
"/api/external/v1/editor/icon-spritesheets/generations" => { "/api/external/v1/editor/icon-spritesheets/generations" => {
let reference_resource_id = json_string_field(&request_body, "referenceId") let reference_resource_id = json_string_field(&request_body, "referenceId")
.or_else(|| json_string_field(&request_body, "referenceImageSrc")) .or_else(|| json_string_field(&request_body, "referenceImageSrc"))
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
endpoint, endpoint,
Some( Some(
"/api/external/v1/editor/images/generations" "/api/external/v1/editor/images/generations"
| "/api/external/v1/editor/scenes/generations"
| "/api/external/v1/editor/icon-spritesheets/generations" | "/api/external/v1/editor/icon-spritesheets/generations"
) )
); );
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1; const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300; const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
#[cfg(target_os = "linux")]
const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000; const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000; const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024; const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let requested = self.request_owned_group_termination(); let requested = self.request_owned_group_termination();
let _ = child.start_kill(); let _ = child.start_kill();
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; let waited = tokio::time::timeout_at(deadline, child.wait()).await;
requested?;
waited waited
.map_err(|_| "等待受控命令主进程退出超时")? .map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?; .map_err(|_| "受控命令主进程退出未确认")?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into()) #[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// leader 可能与取消同时退出;只要组已停止便无需补发信号。
if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
return Err(match requested {
Ok(_) => error,
Err(request_error) => format!("{request_error};{error}"),
});
}
return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
}
#[cfg(not(target_os = "linux"))]
{
requested?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
} }
} }
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let Self::Group { pid, .. } = self; let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 // wait 已回收 leader,不能再用旧 PID 授权发信号。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 // namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
if !linux_project_command_group_has_live_members(*pid)? { return wait_linux_project_command_group_exit(
return Ok(()); *pid,
} tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
)
.await;
} }
#[cfg(not(target_os = "linux"))]
self.request_owned_group_termination().map(|_| ()) self.request_owned_group_termination().map(|_| ())
} }
} }
} }
#[cfg(target_os = "linux")]
async fn wait_linux_project_command_group_exit(
group: u32,
deadline: tokio::time::Instant,
) -> Result<(), String> {
while linux_project_command_group_has_live_members(group)? {
if tokio::time::Instant::now() >= deadline {
return Err(
"受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
);
}
tokio::time::sleep_until(
deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
)
.await;
}
Ok(())
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> { fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> { let inspect = || -> std::io::Result<bool> {
@@ -1842,6 +1882,17 @@ where
), ),
)); ));
} }
// 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
let tree = match ProjectCommandTree::attach(&child) {
Ok(tree) => tree,
Err(error) => {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(error, termination),
));
}
};
if let Err(error) = durable_commit() { if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await; let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new( return Err(ProjectCommandError::new(
@@ -1860,12 +1911,7 @@ where
// cancelled future must not erase the launch-unknown decision window. // cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3)); let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec { match exec {
Ok(TargetExecState::Established) => { Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, child, gate })
}
Ok(TargetExecState::Failed { errno }) => { Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child); let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new( Err(ProjectCommandError::new(
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id); let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err(); let child_kill_error = child.start_kill().err();
let wait_result = child.wait().await; let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
let wait_result = tokio::time::timeout_at(deadline, child.wait())
.await
.map_err(|_| "等待受控命令主进程退出超时".to_string())?
.map_err(|error| error.to_string());
if let Err(error) = &group_result { if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) { let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
return Err(format!("{error};{fallback}")); return Err(format!("{error};{fallback}"));
} }
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?; wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
wait_linux_project_command_group_exit(process_id, deadline).await?;
Ok(format!( Ok(format!(
"{}并完成主进程回收", "{}并完成主进程回收",
group_result.expect("group termination result checked") group_result.expect("group termination result checked")
@@ -2220,7 +2271,7 @@ where
let (exit_code, timed_out, termination_summary) = match wait { let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => { ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?; let terminal = wait_established_project_command_terminal(gate).await;
if let Err(error) = tree.after_main_exit(&mut child).await { if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort(); stdout_task.abort();
stderr_task.abort(); stderr_task.abort();
@@ -2229,6 +2280,12 @@ where
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"), format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
)); ));
} }
#[cfg(target_os = "linux")]
if let Err(error) = terminal {
stdout_task.abort();
stderr_task.abort();
return Err(error);
}
(status.code(), false, None) (status.code(), false, None)
} }
ProjectCommandWait::Exited(Err(error)) => { ProjectCommandWait::Exited(Err(error)) => {
@@ -2541,45 +2598,83 @@ mod tests {
return; return;
} }
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh"); struct DescendantGuard(i32);
command impl Drop for DescendantGuard {
.args(["-c", "sleep 60 & echo $!; read release"]) fn drop(&mut self) {
.stdin(Stdio::piped()) unsafe {
.stdout(Stdio::piped()); libc::kill(self.0, libc::SIGKILL);
command.as_std_mut().process_group(0); libc::waitpid(self.0, std::ptr::null_mut(), 0);
let mut child = command.spawn().unwrap(); }
let tree = ProjectCommandTree::attach(&child).unwrap(); }
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); }
let mut line = String::new(); // 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) for terminate in [false, true] {
.await let mut command = tokio::process::Command::new("/bin/sh");
.unwrap(); command
let descendant: i32 = line.trim().parse().unwrap(); .args(["-c", "sleep 60 & echo $!; read release"])
drop(child.stdin.take()); .stdin(Stdio::piped())
child.wait().await.unwrap(); .stdout(Stdio::piped())
let live_result = tree.after_main_exit(&mut child).await; .kill_on_drop(true);
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); command.as_std_mut().process_group(0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() }; let mut child = command.spawn().unwrap();
assert_eq!( let tree = ProjectCommandTree::attach(&child).unwrap();
unsafe { let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
libc::waitid( let mut line = String::new();
libc::P_PID, tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
descendant as u32, .await
&mut info, .unwrap();
libc::WEXITED | libc::WNOWAIT, let descendant: i32 = line.trim().parse().unwrap();
) let descendant_guard = DescendantGuard(descendant);
}, drop(child.stdin.take());
0 child.wait().await.unwrap();
); let ProjectCommandTree::Group { pid, .. } = &tree;
let zombie_result = tree.after_main_exit(&mut child).await; let error = tree
assert_eq!( .request_owned_group_termination()
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, .expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
descendant assert!(error.contains("leader 身份未确认"), "{error}");
); let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); .await
assert!(error.contains("leader 身份未确认"), "{error}"); .expect_err("持续存活成员必须在预算用尽时失败");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); assert!(error.contains("仍有存活成员"), "{error}");
tree.after_main_exit(&mut child).await.unwrap(); {
let cleanup = async {
if terminate {
tree.terminate(&mut child).await.map(|_| ())
} else {
tree.after_main_exit(&mut child).await
}
};
tokio::pin!(cleanup);
// 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
tokio::select! {
biased;
result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
_ = tokio::task::yield_now() => {}
}
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
cleanup.await.expect("leader 消失后,组成员停止应完成清理");
}
tree.after_main_exit(&mut child)
.await
.expect("僵尸不应阻止完成");
wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect("无活成员时不消耗等待预算");
drop(descendant_guard);
tree.after_main_exit(&mut child).await.unwrap();
}
} }
#[test] #[test]
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
.expect("run timeout test"); .expect("run timeout test");
assert!(timed_out.timed_out); assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed"); assert_eq!(timed_out.status, "failed");
#[cfg(target_os = "linux")]
assert!(timed_out.output.contains("主进程已回收"));
#[cfg(not(target_os = "linux"))]
assert!(timed_out.output.contains("请求终止受控进程组")); assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox")); assert!(timed_out.output.contains("不等同完整 OS sandbox"));
@@ -1,9 +0,0 @@
// 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';
@@ -1,9 +0,0 @@
// 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';
@@ -1,6 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // 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 { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadItem } from './DirectThreadItem';
import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
@@ -86,26 +84,8 @@ export type DirectThreadEvent =
kind: DirectThreadDeltaKind; kind: DirectThreadDeltaKind;
delta: string; delta: string;
} }
| { type: 'request'; kind: DirectThreadRequestKind; requestId: string | null }
| { | {
type: 'queue.enqueued'; type: 'request';
/** kind: DirectThreadRequestKind;
* 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 requestId: string | null;
*/
clientTurnId: string;
/**
* canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。
*/
userItem: DirectCodexUserItem;
creationType?: string;
/**
* 入队那一刻的宿主毫秒钟。
*/
at: number;
}
| {
type: 'queue.removed';
clientTurnId: string;
reason: DirectQueueRemovalReason;
at?: number;
}; };
+1 -2
View File
@@ -23,6 +23,7 @@
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
- [External v1 游戏场景生成路由](./technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md):external v1 结构化场景生成专用路由与 AGC 美术包背景阶段迁移合同。
## AI 游戏创作与 Agent Runtime ## AI 游戏创作与 Agent Runtime
@@ -48,8 +49,6 @@
- [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。
- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。
- [DirectProject 命令接单化实施计划](./technical/【实施计划】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 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
@@ -1,139 +0,0 @@
# 【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-<stem>.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 一并改。
@@ -1052,7 +1052,60 @@
"$ref": "#/components/responses/UpstreamError" "$ref": "#/components/responses/UpstreamError"
} }
}, },
"description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。" "description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。游戏场景不接受本接口的 kind/assetKind = scene,必须使用 /api/external/v1/editor/scenes/generations 提交结构化场景意图。"
}
},
"/api/external/v1/editor/scenes/generations": {
"post": {
"x-mcp-excluded": true,
"tags": ["Editor Images"],
"operationId": "generateExternalEditorScene",
"summary": "生成编辑器游戏场景(结构化场景意图)",
"description": "只接受结构化场景意图:sceneContent + stylePreset(custom 时必须提供 customStyle),完整 Provider Prompt 由服务端组装,不接受调用方提交的完整 prompt。入队后按 kind/assetKind = scene 持久化,产物保存 scene.generate V2 配方;队列、计费、资源入库与画布写回与站内场景路由一致。",
"security": [
{
"ExternalApiKey": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/IdempotencyKey"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditorSceneGenerationRequest"
}
}
}
},
"responses": {
"202": {
"description": "生成任务已持久化入队",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"502": {
"$ref": "#/components/responses/UpstreamError"
}
}
} }
}, },
"/api/external/v1/editor/images/edits": { "/api/external/v1/editor/images/edits": {
@@ -2965,6 +3018,69 @@
} }
} }
}, },
"EditorSceneGenerationRequest": {
"type": "object",
"required": ["sceneContent", "stylePreset"],
"properties": {
"sceneContent": {
"type": "string",
"minLength": 1,
"description": "画面内容(结构化场景意图主体)。纯空白在入队前返回 400。"
},
"stylePreset": {
"type": "string",
"enum": ["anime", "watercolor", "flat", "stop-motion", "custom"],
"description": "视觉风格预设。custom 时必须同时提供非空 customStyle,否则返回 400。"
},
"customStyle": {
"type": ["string", "null"],
"description": "自定义画风描述,仅 stylePreset = custom 时使用。"
},
"model": {
"type": ["string", "null"],
"description": "图片模型,省略时使用服务端默认场景模型。"
},
"aspectRatio": {
"type": ["string", "null"],
"default": "16:9"
},
"imageSize": {
"type": ["string", "null"],
"default": "1K"
},
"referenceImageSrcs": {
"type": "array",
"items": {
"type": "string"
},
"description": "可选参考图,沿用普通图片生成的参考图口径。"
},
"projectId": {
"type": ["string", "null"]
},
"generationInputs": {
"$ref": "#/components/schemas/JsonValue",
"description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。"
},
"assetFolderId": {
"type": ["string", "null"]
},
"assetLabel": {
"type": ["string", "null"],
"description": "省略或纯空白时统一使用「游戏场景」。"
},
"canvasCompletion": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
},
{
"type": "null"
}
]
}
}
},
"EditorImageGenerationRequest": { "EditorImageGenerationRequest": {
"type": "object", "type": "object",
"required": ["prompt"], "required": ["prompt"],
@@ -0,0 +1,43 @@
# 【实施计划】External v1 游戏场景生成路由
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md` |
| Status | ready |
| Owner | Agent |
## 修改边界
- 允许修改:
- `server-rs/crates/api-server/src/editor_project.rs`(抽取场景组装/入队共享逻辑)
- `server-rs/crates/api-server/src/external_editor_api.rs`(新 handler)
- `server-rs/crates/api-server/src/modules/external_api.rs`(路由表与契约矩阵)
- `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试
- AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径)
- `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明)
- 明确不修改:站内场景生成规则、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495);站内入口补齐现有 AGC 账号模式的幂等与结果协议。
## 实现顺序
1. 后端:把 `generate_editor_scene` 的「payload → `EditorImageGenerationRequest`」段抽成 `pub(crate)` 共享函数(含 options 标准化、Prompt 组装、generationInputs 重建、assetLabel 规范化),站内 handler 改为调用它。
2. 后端:`external_editor_api.rs` 新增 `generate_external_editor_scene`:`require_scope(SCOPE_EDITOR_IMAGE_GENERATE)` + `require_idempotency_key` + 共享函数 + `enqueue_editor_image_generation_for_owner` + `external_generation_accepted_response`。
3. 后端:`modules/external_api.rs` 路由表与 `PROTECTED_ROUTES` 各加 `/api/external/v1/editor/scenes/generations`(POST)。
4. 后端:补单测/契约测试(鉴权、参数 400、幂等重放、与站内同 Prompt);同步 OpenAPI JSON 与契约测试。
5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。
6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。
7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。
8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。
## 验证命令
1. `cargo test -p api-server`(重点:external_editor_api、editor_project 场景相关)
2. `cargo test -p shared-contracts`
3. AGC:`cargo test` 于 `apps/ai-game-creator-shell/src-tauri`(定向:canvas_generation、direct_runtime、recovery_scan、manifest)
4. `npm run check:encoding`、`git diff --check`、`npm run check:doc-index`
5. smoke:`npm run dev:api-server` 后 `curl /healthz`,再用测试 Key POST 新路由验证受理与重放。
## 风险与回滚点
- 共享逻辑抽取改变站内路由行为:以「相同输入 Prompt 逐字一致」单测为门禁;回滚点为抽取提交本身。
- 旧路由登记的身份记录在新常量下读不到:保留旧路由 + `spec` 的读取分支,仅新增写入走新路由;回滚点为常量迁移提交。
- 幂等重放依赖现有外部生成重放链路(issue #495 问题一不涉及图片生成入口,实测正常);若 smoke 暴露重放异常,停止合并并记录。
@@ -0,0 +1,46 @@
# 【里程碑】External v1 游戏场景生成路由
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed |
| Date | 2026-09-24 |
| Parent Spec | `docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md` |
## 背景与触发
平台自 `72f268e08`(游戏场景需求 v1.0)起在通用图片生成入口拒绝 `kind = scene` / `assetKind = scene`,要求场景必须走结构化专用路由;但 external v1 没有场景路由,AGC 客户端陶泥儿美术包的背景阶段仍向 `/api/external/v1/editor/images/generations` 提交 `assetKind = scene`,被平台参数校验拒绝(HTTP 400),新项目美术包无法生成(实测产物 `gameagent-f91a64dd`)。同时发现背景阶段保留账本匹配器期望 `assetKind = "game-background"`,与实际请求线上的 `"scene"` 不一致,中断恢复匹配失效。
## 目标
1. external v1 新增 `POST /api/external/v1/editor/scenes/generations`,与站内场景路由共享同一场景 Prompt 组装与入队实现,鉴权、幂等、计费、持久化语义与现役外部生成入口一致。
2. AGC 美术包背景阶段迁移到新路由,以结构化场景意图提交,`reuse-or-create` 与 `regenerate` 均恢复可用,出图风格与尺寸不变。
3. 背景阶段的路由、manifest 身份登记、保留账本匹配与恢复扫描常量整圈对齐到新路由与实际线上值;历史登记保持可读。
## 不在本里程碑内
- 保持站内场景生成规则,补齐账号模式幂等与结果协议;不改通用图片入口的 scene 拒绝校验。
- 不改 SpacetimeDB schema、队列类型、计费档位。
- 不迁移历史背景登记数据。
- 不处理 issue #495 的两个既有问题。
## 依赖与前置条件
- `shared-contracts`:`EditorSceneGenerateRequest` 已存在,直接复用。
- `api-server`:站内 `generate_editor_scene` 的组装与入队逻辑抽取为共享实现。
- AGC `src-tauri`:背景阶段请求构造、保留账本与身份常量迁移。
## 验收标准
- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。
- [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。
- [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。
- [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。
- [ ] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。
- [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。
## 证据要求
- 自动化:`cargo test -p api-server`(契约与场景相关单测)、AGC `src-tauri` 定向测试、`npm run check:encoding`、`git diff --check`。
- 运行时:本地 api-server 受理 smoke。
- 边界:鉴权 401/403、幂等重放、参数失败关闭。
@@ -2884,7 +2884,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-06-22 编辑器生成扣费与新用户赠送收口 ## 2026-06-22 编辑器生成扣费与新用户赠送收口
- 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。 - 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。
- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。新用户注册赠送固定为 `100` 泥点。 - 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。~~新用户注册赠送固定为 `100` 泥点。~~(2026-09-24 更正:注册赠送金额不是固定值,由线上 `profile_wallet_config.initial_mud_points` 配置决定,后台通过 `/admin/api/profile/wallet-config` 与钱包配置页随时调整;代码内常量仅为未写入配置时的兜底默认,本地编译行为不代表线上实际赠送金额,线上数值以配置表当前值为准。契约说明见 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。)
- 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。 - 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。
- 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。 - 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
@@ -9578,36 +9578,3 @@ 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`。 - 验证:`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` 且目录保持未初始化;目录已存在时重启不重写。 - 验证(真实上游 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 必须同批发布/回滚。 - 边界(未验证/残留):上游在售模型超过 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-<stem>.js`,取消不撤);检查是时间点事实、
放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化;
失去"真实二进制驱动执行层生产验证"这条手工路径。
- 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、
`docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。
@@ -1,5 +1,11 @@
# 踩坑与排障记录 # 踩坑与排障记录
## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约
- AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。
- 站内入口必须读取客户端持久化的 `Idempotency-Key`,场景配方重建须保留精确的 `generationInputs.source = ai-game-creator-client` 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 `result`。
- 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 `generationInputs`。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。
## 同一祖先下的多个项目会各自弹一次 UAC ## 同一祖先下的多个项目会各自弹一次 UAC
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
@@ -5994,6 +6000,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 - **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
## Linux command.exec 的 leader 回收与后代退出存在时序差
- bwrap 主进程已经 `wait` 回收时,namespace 后代仍可能短暂处于退出过程;一次 `/proc` 扫描发现活成员后再读取 leader 身份,会把正常退出误报为需人工核对。容器 PID 1 未回收的 `Z / X` 成员也不能当作活进程。
- 正常退出与取消/超时共用有界的组退出确认,组内无活成员立即返回;缺失或变化的 leader 身份不能授权补发信号,持续活成员或读取失败仍报错。启动身份在 ready 后、commit 前记录;terminal 协议错误也不能跳过清理与 reader 取消。
- 回归使用独立 subreaper 夹具:先 poll 清理并确认仍在等待,再让同组后代退出,覆盖正常收尾和取消/超时的共同清理路径;保持现有 CI 分片与并行,不靠取消并行或失败重试消除竞态。
## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息 ## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。 - **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。
@@ -1,144 +0,0 @@
# 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<String>, 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`。
@@ -582,6 +582,8 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变
### V1.11.1 可信 launch 握手 ### V1.11.1 可信 launch 握手
Linux 一次性命令的退出确认按进程组最终状态判断:正常退出、取消与超时在同一有界清理预算内等待组内非 `Z / X` 成员消失,空组立即返回。bwrap leader 已回收而 namespace 后代尚在退出时,不凭一次瞬时扫描报错,也不向无法确认 leader 启动身份的进程组发送信号;持续存活、不可读或归属不明仍失败关闭。启动身份在放行目标前记录,terminal 协议错误也必须完成清理并停止输出 reader。回归用独立 subreaper 夹具控制后代退出时序,保留 CI 的现有并行与分片,不增加测试失败重试。
V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。 V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。
- Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。 - Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。

Some files were not shown because too many files have changed in this diff Show More