Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Native shell tests (pull_request) Successful in 6m11s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Repository checks (pull_request) Successful in 2m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m44s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-24 19:43:20 +08:00
99 changed files with 10278 additions and 1925 deletions
@@ -0,0 +1,36 @@
# AGC 模型目录上游同步实施计划
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-09-24 |
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md` |
## 修改边界与顺序
1. **领域模型(`module-runtime/src/agc_models.rs`)**:删除写死的 `Default` 实现(原 `quality → gpt-6-astra`、`fast → gpt-5.6-luna`),新增 `from_upstream_models`:按上游模型名排序去重后生成目录项(`modelId`/`alias` = 上游原名,`id` = 模型名 slug,`enabled = true`),默认项取排序后第一项;新增 `resolve_requested`(未选或 `platform-default` 用默认项)。字段、校验规则(32 项上限、id/alias/model_id 约束)与 `resolve` 保持原样。
2. **procedure(`spacetime-module/src/agc_models.rs`)**:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 不变。无表结构变化,不改 `migration.rs`。
3. **api-server 目录模块(`src/agc_models.rs`)**:新增启动期 `ensure_agc_model_catalog_initialized`(读 → 解析/校验 → 缺行或非法则 `GET {控制面}/api/pricing?group=taonier` → 生成目录 → 按存量 revision 写回;冲突后重读确认可用);上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;未初始化统一 `503` 文案;后台 PUT 增加未初始化门禁。
4. **api-server 接线(`src/main.rs`、`src/external_api_keys.rs`)**:`try_restore_app_state_for_startup` 按 HTTP 角色调用初始化,失败只 `error!` 记录;抽出 `ensure_llm_router_url_allowed`(只校验地址/scheme,避免被已下线的固定模型哨兵挡住),`LLM_ROUTER_TOKEN_GROUP` / `router_control_origin` 供同步复用。
5. **客户端与后台**:不改。`GET /api/llm/models` 形状、admin DTO、后台「AGC 模型」页、客户端 `select_game_creator_model` 校验全部保持原样。
## 不改的部分
目录字段语义、后台 DTO 与页面、公开 DTO 形状、客户端模型标识校验、`/api/external/v1` 与 OpenAPI、SpacetimeDB 表结构、Router provisioning/额度。
## 验证命令
- `cargo test --locked -p module-runtime --lib agc_models::`
- `cargo test --locked -p api-server --bin api-server agc`、`... llm::`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::`
- `cargo fmt --all -- --check`(两套 workspace)、`npx vitest run apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`、admin-web 页面定向 Vitest 与 typecheck
- `npm run check:encoding`、`npm run check:doc-index`、`npm run check:spacetime-schema`、`git diff --check`
- 运行时 smoke:本地 dev 栈清空 `agc_model_catalog` 后启动 api-server,确认日志 `已按上游模型列表初始化 AGC 模型目录`、库中 `catalog_json` 为「slug id + 上游原名 alias/modelId」、`GET /api/llm/models` 返回原名;再把上游地址指向不可达端口验证 `503` 与「无替代目录」。
## 风险与回滚点
- **上游端点与鉴权**:分组定价列表端点为实测确认的公开只读接口;若上游改版,同步失败只会让目录保持未初始化(接口 503 + 启动 error),不会写入错误模型。
- **混合版本**:module 的缺行语义变化要求 module 与 api-server 同批发布/回滚;未升级的 api-server 会把自己的 AGC 接口打到 `503`(后台 DTO 未变,admin-web 可独立发布)。回滚点必须同时覆盖 module 与 api-server。
- **存量目录**:结构合法的旧目录(含 `quality/fast`)不会自动重建,需要 owner 在后台修改或清空该行后重启。
- **目录规模**:目录项上限仍是 32;上游在售模型超过 32 条时同步会失败并记录原因,需要 owner 在后台维护子集。
@@ -0,0 +1,56 @@
# AGC 模型目录初始值改为上游同步
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress(实现与本地真实上游验证完成;生产发布未执行) |
| Date | 2026-09-24 |
| Parent Spec | `docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md` |
## 背景与触发
`agc_model_catalog` 缺行时,`read_agc_model_catalog` 兜底返回写死的初始目录(`高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)。这两个模型已从上游 Router 移除,于是从未配置过目录的环境(新库、清库、本地调试)会把两个不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次目录才恢复。
## 目标
1. 初始目录不再写死:api-server 启动期从上游分组定价列表生成,`alias` 与 `modelId` 都用上游原始模型名(不再填“高质量/快速”这类人工别名)。
2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。
3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。
## 不在本里程碑内
- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。
- 不做上游变化的自动跟随同步(由 owner 在后台维护)。
- 不改 Router provisioning、额度与计费链路。
- 不改 `/api/external/v1` 与 OpenAPI,不改 SpacetimeDB 表结构。
## 合同要点
- **初始化**:api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),取 `data[].model_name`,按模型名排序生成目录:`modelId` 与 `alias` 为上游原名、`id` 为模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`)、全部 enabled、默认项取排序后第一项,并以存量 revision 写回自增。
- **失败关闭**:拉取失败、空列表、响应超过 1 MiB、缺可解析 revision、写回失败都只记录 error,不写替代目录;未初始化期间 AGC 目录/对话接口与后台目录接口返回 `503`“模型目录未初始化”。
- **重试口径**:只启动期尝试一次;失败不阻塞启动,下次启动重试,直到目录里有数据。请求侧无法触发同步。
- **幂等与并发**:目录只取决于模型集合(排序后生成),重复同步一致;多实例并发只有一个写入成功,冲突方接受既有目录并校验其可用性。
- **存量目录**:结构合法的目录不会被自动重建(包括旧版写死的 `quality/fast`),需要 owner 在后台改掉或清空该行后重启。
## 依赖
- `module-runtime`:`AgcModelCatalog::from_upstream_models`(slug 生成 + 默认项 + 校验),删除写死的 `Default` 实现。
- `spacetime-module`:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`。
- `api-server`:启动期 `ensure_agc_model_catalog_initialized`;上游请求硬化(10s 超时、1 MiB 流式上限、禁止重定向、不带凭据);`ensure_llm_router_url_allowed`(只校验地址,不绑定已下线的固定模型)。
- 文档:主规范、后端数据契约、运维文档、decision-log。
## 验收标准
1. 空目录 + 上游可达:启动后目录自动生成(别名即上游原名),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 是上游原名,界面不出现内置模型名。
2. 空目录 + 上游不可达/非 2xx/空列表:启动只记录 error、不写替代目录;AGC 与后台目录接口 `503`;上游恢复后重启即同步成功。
3. 幂等:同一模型集合重复同步得到一致的目录与默认项。
4. 目录领域校验(id/alias/model_id、32 项上限、默认项必须启用)与请求侧 `422`/`409` 行为与改动前一致。
5. 回归:Rust 定向测试、AGC/admin-web 类型检查与定向测试、`npm run check:encoding`、`check:doc-index`、`check:spacetime-schema`、`git diff --check`。
## 已决与待决
- 已决:上游来源用分组定价列表(2026-09-24 实测:`/v1/models` 用管理 token 返回 401;管理面注册表会带出已下线、无路由绑定的模型)。
- 已决:`id` 用模型名 slug,保持客户端标识校验契约不变。
- 已决:初始化失败不阻塞启动,只在下次启动重试。
- 已决:目录结构与既有 DTO/页面保持不变,本变更不引入不兼容改动。
- 待决:是否需要“上游自动跟随同步”(当前不做)。
@@ -8,6 +8,124 @@
- 代价与取舍:右键从"按下即弹菜单"变成"抬起才弹"(与 Windows 自身右键菜单一致);预览内空白处的浏览器原生菜单被永久抑制;右键平移与节点菜单互斥(越过阈值后抬起不再弹菜单);`grabbing` 光标在悬停到节点上时仍会被节点自身的 `cursor-move` 覆盖,与共享画布现状一致。本次只改 UI 编辑器预览:AGC 美术画布仍只有空格/中键平移,资源画布保留自己的右键平移实现,都不动,也不抽 `packages/shared`。
- 验证方式:新增 `previewRightPanGesture` 纯状态机单测(阈值跨越、起点全量 delta、拖拽吞菜单 / 干净抬起开菜单、取消与失焦清理),并在 `previewRightPanDrag.test.tsx`、`previewRightPanGesture.test.ts` 补右键回归(含键盘菜单键 `button: -1` 不被拦截的用例);运行 `npx vitest run`(定向文件)、`apps/ai-game-creator-shell` `npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`、`git diff --check`。
## 2026-09-24 接单化 review 收口(第二轮):失败载荷分类、拒单身份与提示口径
- 决策(失败载荷的 `kind` 收成 typed 枚举):新增 `DirectTurnFailureKind`(`Serialize + Deserialize + TS`,
`kebab-case`,7 个变体,含先前两份名单都漏登记的 `turn-interrupted`),`DirectTurnError::wire_kind`
返回 `Option<DirectTurnFailureKind>`。线上仍是 `{kind, message}`、取值不变,只有 TS 侧从裸 `string`
变成可穷尽收窄的联合类型;全仓没有按 `failure.kind` 分流的代码,它只给界面选语气。
- 决策(并发拒单的两个身份是回合身份):`DirectThreadManager::accept_turn` 冲突时返回占用对象的
`turn_id`,`DirectTurnReservation::accept` 把这一轮请求的 `clientTurnId` 传成
`incoming_invocation_id`。改动前这两项是进程内 UUID,`TurnAlreadyRunning` 的"同一轮仍在处理中"
分支永远命中不了,也与"回合身份由 `clientTurnId` 推导"的口径冲突。占用对象自己的 `token` 仍是
UUID(`complete_direct_thread_turn_if_reserved` 靠它配对),只换错误载荷里的两项。
- 决策(目录锚不定的拒单不再写诊断):`DirectTurnError::ProjectRootUnanchored` 从 `is_reportable()`
拿掉,与 `ProjectRootUnusable` 同类——符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实。
改动前它被命令边界覆写成 `direct-codex-failure:v2` 收口文案,界面上那句"无法锚定 Direct 调用项目
目录:{cause}"被内部诊断串顶掉;现在界面按 `Display` 显示,也不再进 `.agent/runtime/errors`。
可留痕的拒单只剩 `environmentNotReady` / `hostStateUnavailable`。
- 决策(认不出的拒单也要在聊天里有同级提示):`environmentNotReady` / `hostStateUnavailable` 除上报 +
横幅外,再补一条与用户消息同级的提示——拒单没有接单、不产生 `turn.completed`,否则那条乐观用户
气泡后面永远没有解释(改动前的注释"宿主已经把它放进了 `turn.completed.failure`"对拒单不成立)。
文案走 `projectRuntimeVisibleRejectionError`:取宿主收口文案里已脱敏的摘要与建议,**不套阶段标签**
(拒单这一轮没有开始,阶段只会是默认值);非结构化错误仍只走横幅(它可能发生在接单之后)。
- 决策(失败说明的文案口径):`projectRuntimeVisibleError` 补上宿主 `Display` 事实句的模式
(`执行通道已断开` / `等待模型回合结束达到硬上限` / `宿主任务提前结束` / `收尾历史失败` 一族),
并给落盘那档补上不带"失败"二字的事实句;不回落宿主原文(`TransportClosed` 的原文带 `exitStatus=` /
`stderrClass=`)。同时修掉收口文案的版本口径:解析只认 `v1`、宿主发的是多一段 `code=` 的 `v2`,
脱敏摘要一直命中不了。口径定为"不加模式就只会看到通用文案",写在 `directTurnFailure.ts` 的注释里。
- 明确不做:不改线上载荷形状与 `kind` 取值;不加新的失败阶段取值(拒单仍落默认阶段);不动
`ProjectRootUnanchored` 之外的拒单分类。
- 决策(连接死亡的失败事实先于看门狗可见):`CodexAppServerInner::closed` 的语义定为"这一段已经收束 /
失败事实已经记下",看门狗就盯着它,所以它不能再兼作死亡收口的去重标志——去重改用私有的
`connection_end_claimed`,`fail_game_creator_codex_app_server_connection` 不再置 `closed`,
`closed` 只在 `shutdown_game_creator_codex_app_server_inner` 里、`record_execution_turn_failure` **之后**
置位。改动前收口路径先置 `closed` 再做"两次加锁 + 一次日志写",200ms 看门狗可能在这一段里抢跑,把
这一轮收束成 `Interrupted`,typed `TransportClosed` 记不进去,终态退化成"本轮已结束、没有原因"
(失败事实是在模型终态那一刻被快照的,晚补记无用,所以只能保证"事实先于可见性")。代价是其它读
`closed` 的地方会晚几十微秒看到"连接已死",两个并发的死亡观察者仍会各自走到幂等的收束函数。回归用例
`connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn` 卡住 stderr 摘要锁把窗口
拉成确定性,把看门狗真正跑起来钉这条(顺序反了就红)。
- 决策(接单之后的失败不回命令返回值):`chat_with_game_creator_direct_codex_typed` 在接单后的历史追加
写失败时仍然写 `turn.completed` 失败终态,但 `return Ok(())`——命令的 `Err` 只表示**拒单**。改动前同一
个失败从"事件里的说明"和"命令 `Err` 的横幅"两条通道下发(且 `EnvironmentNotReady` 会写诊断 + 上报),
前端又把 `Err` 当"这一轮没开始",于是忙态与出队同时被事件和返回值两条路推。**不继续起整轮**:
`project.jsonl` 是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户消息的助手回复,
失败还会被静默。用例:Rust `a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting`
(恰好一条失败终态、不带拒单收口文案、占用释放)、前端 appSurface 的落盘失败用例(说明只来自事件且
恰好一条、忙态放掉、下一条能发)。
- 影响范围:Rust `apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/mod.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_turn_accept.rs,direct_thread_manager.rs,direct_runtime/user_input.rs}`;
前端 `src/features/agent-runtime/model.ts`、`src/view/project-development/chat/{conversation/directCodexConversation.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、
`src/view/project-development/chat/generated/DirectTurnFailureKind.ts` 与
`tests/{agentRuntimeModel.test.ts,directThreadChat.test.ts,appSurface/chat-composer.suite.ts,appSurface/project-conversation.suite.ts}`;
文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`。
- 验证:Rust `cargo test --bins "agent::"`(902 passed / 5 ignored)、定向
`cargo test --bins "agent::direct_turn_error"`(15 passed)、`cargo fmt`;前端
`npx vitest run tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}`
(277 passed / 9 skipped)、`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、
`git diff --check`。真实客户端观感未复核。
## 2026-09-24 接单化 review 收口:终态写点、返修控制流、终止判据与失败投影
- 决策(终态的写点在整轮真正结束之后):Direct 回合先固定终态判定的上下文,`turn.completed` 的写出
挪到执行结果收集、历史落盘、structured output 解析都定型之后,成功与失败共用一个写点。解析失败也是
这一轮的失败,落进同一份失败载荷;改动前终态先写、再解析,解析失败时终态已是 `completed`,占用对象
的兜底变成空操作,用户看到"本轮结束、没有回复、没有任何解释"。收尾结果因此拆成
`DirectTurnReport`(报告正文 + 解析结果),占用解除与终态事件一起走 `DirectTurnTerminalContext::write`。
- 决策(封口返修要求是控制流,不是失败):`HostOutcome::RepairRequired` 不再伪装成
`LlmError::InvalidRequest("validation-source-changed: …")`,改为 typed 的
`DirectTurnRunFailure::RepairRequired` → `DirectTurnError::RepairRequired`:不写终态、不进载荷、不上报,
由 `direct_runtime` 的返修循环写回提示词继续跑(与 `ReviewRequired` 同一族,次数上限仍留在产生侧)。
改动前它被判成 `failed` 终态、界面收到一条假失败,还会让同一个逻辑回合写出第二条终态。
- 决策(用户按下的终止不算通道失败,判据收进 `fail_turn`):失败事实的判据是
`!is_closed() && !host_stop_requested()`,不再由各调用点各写一遍 `!is_host_ending()`。用户点「终止」时
标志先置位、阶段后变,原来的窗口里到达的 `TransportClosed` 会把用户自己的终止记成 `transport-failed`。
- 决策(登录态失效的两条分类路径统一可重试):认证失败不再按"重跑整轮"处理,刷新失败与重试失败都按
可重试的回合失败呈现(用户可见文案可能多一句"可直接重试",真实客户端观感未复核)。
- 决策(失败载荷的健壮性):前端 reducer 对 `failure.message` 做运行时判据(缺字段 / `null` 不再抛错,
与 `directTurnFailureNoticeText` 同口径);交付报告兜底只读一次 `terminal_report`(两次读取之间状态可能
变化,`None` 不再被 `unwrap_or_default()` 变成空回复);失败说明条目在无身份无时间时会撞成同一条
(已知边界,仅补注释)。
- 明确不做:不改线上载荷形状(仍是 `{kind, message}`);不给 DirectProject 回合补端到端集成用例(缺轻型
假 app-server 夹具),判据落在策略函数与适配器单测;不持久化"可见但不喂模型"的失败条目(TODO)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs},direct_runtime/{mod.rs,user_input.rs},direct_turn_error.rs}`、前端
`chat/{conversation/directThreadChat.ts,generated/DirectTurnError.ts}` 与 `tests/directThreadChat.test.ts`;
文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:`cargo test --bins "agent::"`(952 passed)、`cargo test --bins "direct_"`(475 passed)、定向
`codex_app_server`(102 passed)、前端 `directThreadChat.test.ts`(36 passed)与
`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核
(终态写点与终止竞态落在真实宿主收尾上,单测盖不住)。
## 2026-09-23 Direct 回合错误改 typed:调用级拒绝与回合级失败分开
- 回合失败在宿主内部改成 typed 的 `DirectTurnError`(`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs`):每个变体自带字段,调用级拒绝(并发复用同一 `clientTurnId`、另一条回合在跑、权限策略拒绝、目录锚不定、输入校验、环境/凭据未就绪)与回合级失败(模型调用失败、通道断开、等待超时、app-server 单方面中断、阶段失败)不共用判据,分流只认 `is_turn_failure()`。
- 根因:改造前两层错误混在同一份字符串里,靠对原因文本做子串匹配决定"算不算失败""要不要反馈给模型""怎么给建议",任何文案改动都可能静默改变分流;并发拒绝还只靠一个前缀字面量给前端识别。
- 分类不再做文本匹配:原生失败分类只解析 app-server 写下的 `codex-app-server-error:<kind>` 结构化前缀,转成 `DirectCodexNativeKind` 后再 `match`。
- 明确不做:不改线上载荷(仍是 `{kind, message}`)、不改命令边界签名(仍是 `Result<String, String>`)、不改前端可见文案映射与 `wire_kind` 取值;Rust 侧不再解析那份字符串,字符串只在 `Display` 一处生成。不给深层尚未 typed 的事实补 typed 出口,只留一个显式的桥变体并在注释里写明新分类必须先加 typed 变体。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs,direct_turn_failure.rs,direct_delivery.rs,direct_runtime/mod.rs,direct_runtime/user_input.rs,codex_app_server/mod.rs,codex_app_server/execution.rs}` 与 `apps/ai-game-creator-shell/src-tauri/src/cli.rs`;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。
- 验证:`cargo test --bins -- direct_`(460 passed)、`cargo test --bins -- codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、定向 `git diff --check`。整包 `cargo test --bins` 在本机被既有 `tests::provider` / `tests::project` 重型用例挂住(并发跑测试时另有一条锁竞争用例会假失败),非本次改动引入。真实客户端观感未复核。
## 2026-09-23 ACL 提权修复按目标做 single-flight
- 背景:`windows_acl_repair_target` 对 Managed 作用域返回的是「第一个读取被拒的祖先」,同一祖先下的多个项目会解析到**同一个** repair target;而唯一的去重只是单次调用内的局部 `attempted_targets`。于是启动页一次挂载(≤8 个最近项目并发检查)会启动同样多次 `powershell -Verb RunAs`,用户看到叠在一起的 UAC 弹窗(issue #498)。
- 决策:新增进程级闸门 `acl_repair_gate`,key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**;结果在冷却窗口内直接复用(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭。leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住。
- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。
- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。
- 决策补充(leader 失效接管):`leader_deadline`(默认 5 分钟)之后,新调用可以接管仍是 `running` 的 key;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者的结果。真机上无人应答的 UAC 约 2 分钟自然超时,所以这个上限只兜「提权子进程真挂死」——否则该目标会永久按失败关闭(`clear_denials` 不清理 running,只能重启客户端)。
- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。
- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
## 2026-09-24 DirectProject 状态条口径翻转、几何约束与对话 Markdown 容错
- 背景:AGC DirectProject 对话区底部的「陶泥儿正在处理 / 已耗时 12.4秒」状态条同时退化三处:① 读秒 1 秒一跳(耗时文案不足一分钟显示一位小数,小数位却一秒才动一格);② 窗口压矮时被挤扁(300px 高压到 33px、240px 时 24px,文字被 `overflow: hidden` 裁掉);③ `turn.started` 之前(模型首 token 前,实测约十秒)整条卡片不出现,界面没有任何「正在处理」的交代。同批还修了对话 Markdown 的两处代码块问题(不换行把消息拉宽、粘在正文行里的围栏导致代码块解析错位)。
- 决策(卡片口径翻转,**更正** 2026-09-22「卡片口径取保守」):卡片与已耗时起点改读 `displayBusy`(本地命令在飞 ∪ 原生已确认在跑)与「最新一个**未结束**回合的用户发送时间」。理由:`turn.started` 要等宿主应答返回才发出,只认原生真相会让首 token 之前那段没有交代;窗口期这一轮确实已经交给宿主(本地命令在飞),文案不虚报「宿主已在跑」之外的东西。(**再更正** 同日:本地乐观气泡已删,已耗时起点改读该轮的 `turn.started.at`——运行中读实时值、收口后读盖在条目上的值;接单窗口里还没有这一轮的条目,卡片只报「正在处理」、这一段不读秒。卡片口径本身不变:仍读 `displayBusy`。)
- 决策(那条预言的处置):2026-09-22 那条写「若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,应把投影压成 `unfinished: boolean`」。本次改完后投影三态**仍有**消费者(**再更正** 同日:投影已压成两态 `running` / `finished`,`awaiting-start` 随本地乐观气泡一起删除;下面这两条消费者读的判据不变)——`DirectProjectTurn` 用 `state !== 'finished'` 做否定式判断、`state === 'running'` 挑流式正文,状态条也用 `state !== 'finished'` 定起点——所以不动 `DirectChatTurnState`,也不压缩成布尔。
- 决策(状态条几何):卡片在 `.project-chat-conversation` 这条定高 flex 列里必须 `flex: 0 0 auto`。它带 `overflow: hidden`,按 flex 规范该项的自动最小尺寸归零,是这条链上唯一还能被压缩的项;压缩只能由消息列表吸收。同一选择器只保留一条规则(几何 + 不可压缩),不留两份。
- 决策(对话 Markdown 对模型输出的容错):解析前先 `normalizeMarkdownFences` 再压缩空行;代码块 `pre` 与块内 `code` 各自都给 `whitespace-pre-wrap` + `break-words`。细则与判据见 `pitfalls.md` 同日两条。
- 影响面:`apps/ai-game-creator-shell/src/{styles.css,components/ChatMarkdownMessage/index.tsx,view/project-development/chat/{DirectProjectChatView.tsx,components/DirectProjectConversation/DirectProjectConversation.tsx,controller/useDirectProjectTurnStatus.ts}}`;用例 `tests/{ChatMarkdownMessage.test.tsx,directProjectProcessStatus.test.tsx,appSurface/{chat-composer.suite.ts,project-development.suite.ts}}`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests` 186 passed / 1 skipped(1874 条里 1860 passed / 14 skipped);真实 Chromium 夹具复核读秒 100ms、窗口 900→220px 高度下卡片恒为 36px 不被裁、代码块换行与两类粘住围栏;变异验证(读秒改回 1000ms、删 `flex: 0 0 auto`、卡片退回 `nativeRunning`、停掉围栏归一化、换行类名退回)逐条变红。
## 策划 V1/V2 退役的现行边界
- 旧策划 V1 和 Runtime V2 均已删除,当前策划入口统一使用独立 Design Agent。V1 被 V2 接替只描述历史过程,不表示 V2 仍在使用。
@@ -9332,14 +9450,52 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx` 的 `DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts` 的 `DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start` 与 `running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`(203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding`、`check:doc-index`、`git diff --check` 通过。真实客户端观感未复核。
## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口
## 2026-09-22 失败回合的终态:`turn.completed` 带 `failure` 载荷 + 宿主 Drop 守卫兜底
- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈)。
- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合)。
- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。
- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。
- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。
- 背景:宿主崩在 `turn.started` 之后时没有任何终态事件,前端 `turnRunning` 永远为真,界面停在「陶泥儿正在处理」;同时失败在事件流里与正常结束同形(`turn.completed(status="failed")`,前端根本不读 `status`),失败文案只能从命令返回那条通道另造,同一次失败因此有两条通道、两份文案,而"这一轮结束了没有"只有事件说了算。
- 决策(协议形状:复用,不新增事件类型):终态事件仍只有 `turn.completed`。`status !== "failed"` 表示正常结束 / 中断 / 终止,不带载荷;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }` —— `kind` 为稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气;**2026-09-23 追加 `environment-not-ready`**),`message` 为宿主脱敏 + 截断后的可展示原因。"是不是失败"只看两件事:`collect_result` 是 Err 就用错误本身当原因;`collect_result` 是交付报告但状态已判成 `failed` 就用那份报告当原因。
- 决策(兜底覆盖全部收场路径):`turn.started` 进入队列之后武装 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `host-dropped` 失败终态。Thread Manager 不改一行:`turn.completed` 本来就是 `lifecycle_anchor` 成员,失败终态天然顶替更早的 `turn.started`,重放不会把已收口的回合看成"还在跑"。(**已由 2026-09-23「DirectProject 命令接单化」取代**:守卫换成接单时登记的占用对象,接单前的早退改判为拒单、不再产生回合,`host-dropped` 只保留给"说不出原因"的一类。)
- 决策(失败文案只有一条通道):聊天里那条失败说明仍落在原来的展示位(本轮最后一条助手气泡、只在运行期显示、不写进 `project.jsonl`),数据来源换成事件载荷;命令返回只保留运行错误横幅(含 `read_agent_runtime_error_detail` 的长 detail)与诊断留痕,不再写聊天气泡。可见文案映射仍走既有 `projectRuntimeVisibleError` 规则,只是执行点从 controller 移到 reducer。
- 明确不做:不为 `turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`、缺稳定 `clientTurnId`、历史注入参数构建失败)补事件或兜底路径 —— 它们不产生回合、也不会留下永远开着的回合;不为进程被强杀(`kill -9`)补前端判据。(**已由 2026-09-23「DirectProject 命令接单化」取代**:接单前的失败改判为拒单、由命令边界返回 typed 错误;接单后的这类失败由占用对象收口成 `turn.completed`;`kill -9` 的界面表现在新 ADR §2。)
- 同日被取代的还有本条目里的另一条:命令返回只保留"运行错误横幅 + 长 detail"——`详情:` 引用与 `read_agent_runtime_error_detail` 已删除,失败说明也不再落命令边界(改由宿主投影写事件载荷与诊断池)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_thread_wire.rs,direct_turn_failure.rs,direct_thread_manager.rs,codex_app_server/mod.rs}`、`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,conversation/directTurnFailure.ts,controller/useDirectProjectChatController.ts}`、生成绑定与两侧用例;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:见本条决策对应的提交记录(Rust 定向测试、reducer 与 appSurface 用例、`cargo test export_bindings` 后的生成绑定、`npm run check:encoding`、`git diff --check`)。
## 2026-09-22 执行通道断开也是失败终态:诊断记在执行适配器上,不与看门狗抢时序
- 背景:手工杀掉 codex app-server(`kill -9`)验证上一条修复时,回合确实收口了(界面不再停在"还在处理"),但**没有任何失败说明**:连接级故障走的是 `TransportClosed` 分支,那里用一句策略文案 `adapter.interrupt(...)` 收束成 `ExecutionPhase::Interrupted`,`lifecycle_status` 把 `Interrupted` 映射成 `status="interrupted"`,`direct_turn_failure` 因此返回 `None`,事件不带载荷、reducer 也就不落说明条目;真实诊断(`Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);stderrClass=...`)只进了 `app_log!`。
- 决策(失败事实记在执行适配器上):新增 `ExecutionAdapter::transport_failed(diagnostic)` 与只读的 `transport_failure()`。连接级故障(`fail_game_creator_codex_app_server_connection`)与回合事件通道关闭(`TransportClosed` / 事件通道 `None`)都调它:先同步记下"本轮以传输失败收口"与原因,再把同一份原因补进宿主交付报告(`interrupt` 对已有终态不覆盖,报告只作旁证)。`lifecycle_status` 见到这条事实一律返回 `failed`,`direct_turn_failure` 因此产出 `kind="transport-failed"`、`message=诊断` 的载荷。原因不能存在调用点局部变量里:执行适配器的看门狗盯着同一个 `inner.closed` 标志,它可能先把回合收束成 `Interrupted`,而终态判定发生在收束之后。
- 决策(区分"连接自己断了"与"宿主关的连接",判据收在适配器里):`transport_failed` 先看 `is_closed`——宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)时适配器先于连接置位 `closed`,那种情况下只按既有口径中断收口(原因照样写进报告),不记失败事实;调用点两条分支的判据保持原样(`!is_host_ending()`),不动它们的控制流。
- 决策(载荷取诊断而不是交付报告):`direct_turn_failure` 增加第三来源且优先级最高——通道断开时原因用宿主诊断(含 `exitStatus` / stderr 摘要),不用 `collect_result` 里那份只说"收束到哪一步"的交付报告;报告与载荷同源的说法只对"原因写进报告"这一步成立,事件载荷才是失败原因的唯一权威。
- 明确不做:不改前端可见文案映射(诊断命中不了专门规则,仍落到通用兜底文案);不给连接级故障补端到端集成用例(判据落在策略函数与适配器两层单测,DirectProject 回合路径缺轻型假 app-server 夹具);`kill -9` 掉宿主进程本身仍没有 `Drop`,不在本次范围。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_`(439 passed)、定向 `transport_failure` / `direct_turn_failure::`(10 passed,含新增三条:适配器把诊断记成失败终态且只认第一份原因、宿主自己关的连接不算失败、失败载荷优先取宿主诊断)、`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。真实客户端观感未复核。
## 2026-09-23 终态由事实判定:模型自报失败的原生错误投影进既有错误通道
- 背景:app-server 老实报了 `turn/completed{status:"failed", error:{message, additionalDetails, codexErrorInfo}}`,界面却没有任何原因。两条通道同时哑:① 事件侧 `lifecycle_status` 拿收尾阶段当终态口径(执行适配器 `drain()` → `interrupt()` 把 ledger 推成 `Interrupted`),把模型报的 `failed` 改写成 `interrupted`,失败载荷因此永远产不出来;② 命令侧有执行许可时 `finish_model_attempt` 先返回交付报告,命令变成 Ok,运行错误横幅的前提也消失。原生 `turn.error`(带 `codexErrorInfo` 分类,前端 `projectRuntimeVisibleError` 有现成中文映射表)只在"没有执行许可"的 `Err` 分支里被读一次。
- 决策(终态由事实判定,有载荷必 `failed`):`direct_turn_terminal` 去掉 `model_status` 入参,判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有收尾阶段账本读不出来时才用交付报告」取原因;**有载荷一定写 `status="failed"`**,没载荷才用收尾阶段推出来的 `status`。收尾阶段的中断不再有终态否决权——这是本次修复的根因。
- 决策(原生失败用投影,不扩载荷、不加入参):`turn.error` 由既有的 `game_creator_codex_app_server_failed_turn_error` 投影成 `LlmError`,在 `"failed"` 分支里作为本回合的错误结果返回,于是走已有的错误槽位产出 `{kind, message}` 载荷;`RepairRequired`(返修请求)保持原语义优先。前端零改动——`projectRuntimeVisibleError` 现有映射直接命中 `codex-app-server-error:<kind>`;命令返回同时回到 Err,运行错误横幅恢复。
- 明确不做:不新增事件类型、不给失败载荷加字段、不改前端可见文案映射;不给回合路径补轻型假 app-server 夹具(判据落在 `direct_turn_terminal` 与执行适配器两层单测)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{mod.rs,execution.rs}`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs` 与其单测;文档 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 与 `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`。
- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins direct_`(442 passed)、`codex_app_server`(100 passed)、`cargo fmt --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。真实客户端观感未复核。
## 2026-09-23 DirectProject 命令接单化:命令只接单,逻辑回合归 Thread Manager
- 背景:`chat_with_game_creator_direct_codex` 一个命令调用覆盖整轮(校验 → 跑 → 交付验证),于是命令边界同时兼职"接单被拒"与"回合失败"两种回执:失败文案有事件载荷与命令 Err 两条来源,`withDirectCodexSessionRefresh` 的"刷新会话 + 重跑整轮"会重复落盘一条用户消息,认证重试只能挂在 Err 上;而回合边界又镜像 Codex 原生回合(`turn/start` 成功应答后才发 `turn.started`),"接单到 `turn/start` 之间"的失败(连不上 app-server、配置未就绪、历史注入失败、`turn/start` 被拒)没有任何事件可解释,命令一旦不 await 就会静默。
- 决策(命令 = 接单 / 拒单):命令只做 `clientTurnId` 校验 → 占用调用身份(只挡并发,早于工程准备)→ 工作流恢复 → 用户条目校验 → 工程准备 → 接单成立 → 用户条目落盘 → 起 codex,成功后立刻返回、不等回合。命令返回类型改成结构化的 `DirectTurnError`(ts-rs 已导出到 `chat/generated/`,与 `DirectThreadEvent` 同一套 `cargo test export_bindings` 流程)。
- 决策(分流判据从"错误种类"改成"发生位置"):**接单之前**的失败(目录、权限、输入、并发、工程准备未就绪、宿主状态取不到)是拒单——不产生回合事件、不写用户条目、不写失败诊断;**接单之后**的失败(连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切)是回合失败,只走 `turn.completed` 带 `failure` 载荷一条通道。`DirectTurnError::EnvironmentNotReady` 接单前后都可能出现,因此新增自己的失败分类 `environment-not-ready`(否则投影会写成 `model-failed`、界面语气就错了)。这条位置判据取代原先"调用级拒绝直通"的分支。
- 决策(逻辑回合由 Thread Manager 拥有):新模块 `agent/direct_turn_accept.rs` 按 thread 维护占用登记,`accept(thread, user_item_id, client_turn_id)` 在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`DirectTurnReservation::finish(terminal)` 幂等写出 `turn.completed` 并释放占用,`Drop` 兜底补 `host-dropped` 终态。发点在接单时、不再镜像 Codex 原生回合(原生事件留在适配器内部,不再进事件队列),线上仍只有一对 `turn.started` / `turn.completed`。因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性成立,不依赖实现者给每条早退路径补事件。并发锁与首页快照的口径:`DirectTaonierActiveInvocation` 退回纯单飞锁,首页"运行中的项目"改由 `list_direct_active_turns` 从 TM 的逻辑回合导出(`ActiveDirectTurn` 带快照字段,`DirectActiveTurnSnapshot` 移入 `direct_thread_manager.rs`),不再留两处事实。
- 决策(回合身份由 `clientTurnId` 推导):`turn.started` / `turn.completed` 的 `userItemId` 按 `direct-codex:{clientTurnId}:user` 算出(与前端 `directCodexConversationMessageId` 同规则),**不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败。
- 决策(界面:同级提示、删除 `详情:`):失败说明与接单被拒提示都与用户消息**同级**、按事件顺序排在它后面,不嵌在这条用户消息里;删掉 `详情:` 引用、它的正则解析与只服务详情展开的第二次 IPC `read_agent_runtime_error_detail`;顶部状态行只显示回合状态,不承载错误文本。前端按 typed 变体分流:认得的"前置条件不满足 / 用户参数无效"(`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` / `projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` / `contentEmpty`)→ 出同级提示、不走 `captureAgentRuntimeError`;认不出的变体(`environmentNotReady` / `hostStateUnavailable`)以及非结构化错误 → 抛出,走既有捕获上报链路。
- 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。
- 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。
- 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。**
- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。
- 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。
- 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。
- 验证:Rust 定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`(949 passed);前端 `NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed);`npm run ai-game-creator-shell:typecheck`、`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。真实客户端观感未复核。
## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗
@@ -9388,3 +9544,37 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。
- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。
- 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。
## 2026-09-24 DirectProject 失败说明按回合身份归位:开口用户条目发点提前到接单之后
- 背景:用户在同一个项目里连发消息,每条都在**连接获取阶段**就失败(执行器版本未通过逐次审批协议验收),界面上"错误显示在用户消息上面",上一轮还顶替本轮显示耗时(现场 15.6 秒),本轮气泡自成一轮显示 0.0 秒;后面再发一条,说明落进更早的分区里,用户以为"这条没报错"。区分两个 `turn/start`:逻辑回合的 `turn.started` 由接单动作发出(成对、一定有);app-server 协议的 `turn/start` 请求在连接拿到之后才发。失败发生在后者之前。
- 根因:本轮的**开口用户条目**(`item_completed`,身份 `direct-codex:{clientTurnId}:user`)原来在 app-server `turn/start` 应答之后才下发,于是"接单到 `turn/start` 之间"的失败没有用户条目可挂;前端 `buildDirectChatTurns` 按**条目顺序**分回合,失败说明只能落在上一轮末尾,而本轮的乐观气泡被排在所有正式条目之后 → 渲染成"错误在用户消息之上"。
- 决策(宿主):开口用户条目的**发点**提前到"接单成立、用户条目落盘成功、起 codex 之前"(`emit_direct_thread_user_item`,调用点 `direct_runtime/user_input.rs` 的命令主体),删掉 `turn/start` 之后那一处;线上仍然只有一处下发,不变式变成 `接单 → 开口用户条目 → 整轮里其余一切`。
- 决策(前端):回合归属只认**身份**——失败说明条目带 `turnUserItemId`(reducer 写),`buildDirectChatTurns` 按开口条目身份分组(同一身份的条目永远同一轮),本地乐观气泡按身份挂回自己的回合而不是另开一轮;reducer 的收口早退只挡重复终态,不再吞掉"订阅重建只回放生命周期锚点"时那条还没写进界面的失败说明(`direct_thread_manager.rs` 的 `lifecycle_anchor`)。
- 影响面:`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}`。
- 边界:`project.jsonl` 里的用户条目依旧只在首屏 / 翻页时读进前端,本轮不改读取时机——开口条目的运行态下发 + 身份归位已经让"说明挂错回合"不成立。
- 验证:宿主 `cargo test --bins "agent::"`、`the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、`direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(补上同一发点);前端 `directTurnPresentation.test.ts` 的"本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合"、`directThreadChat.test.ts` 的两条(身份字段、收口早退不吞说明)。
## 2026-09-24 DirectProject 删掉本地乐观用户气泡:用户气泡只来自宿主条目
- 背景:接单化之后,"接单窗口期"只服务本地乐观气泡(`awaiting-start` 展示态 + `pendingUserItemId` 身份)。上一轮把开口用户条目的发点提前到接单之后,"说明挂错回合"已不再需要气泡兜底;用户确认按"这条消息就像从来没存在过"处理,直接删干净。
- 决策(不造用户消息):删 `pendingUserItemId` / `beginTurnCommand` / `endTurnCommand`(忙态改由 `beginTurnBusy` / `endTurnBusy` 持有,宿主认领判据 = `turnRunning` 或收口计数变过)、权限确认重跑的 `messageAppended` 参数、`DirectProjectTurnInput.messageText`(含首轮 `directInitialTurnText`)、投影里的 `awaiting-start` 与本地用户气泡路径(展示态只剩 `running` / `finished`);controller 不再需要 `assets`。
- 决策(时间口径):回合起点只认 `turn.started.at`、终点只认 `turn.completed.at`;用户气泡的时钟是宿主落盘 / 观测时间,不再有"本地更早的真实发送时刻"(`sameIdentitySentAt` 删除)。两边都拿不到(重进项目读回来的历史回合)时整条「本轮结束于 … 」隐藏,不再兜出 0.0 秒。
- 决策(本地说明):拒单提示带自己的身份(`…:rejected`),投影据此在会话末尾自成一组,不挂进上一轮;壳层 `announce`(无身份)照旧挂当前回合末尾。带身份的本地说明不开运行态标记,避免把真正在跑的那一轮读成已结束。
- 代价(已知并接受):接单窗口与订阅重建窗口里聊天区没有这一轮的显示,反馈只有 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段还读不出「已耗时」——起点是宿主的 `turn.started.at`,开始事件到了才开始读秒);`project.jsonl` 用户条目依旧只在首屏 / 翻页读进前端。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/{directTurnPresentation.ts,directCodexConversation.ts}`、`.../chat/controller/{useDirectProjectChatController.ts,useDirectProjectTurnStatus.ts}`、`.../chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`src-tauri/src/agent/codex_app_server/mod.rs`(用户条目时间的注释口径)、对应 ADR 与实施计划。
- 验证:`npx vitest run tests/directTurnPresentation.test.ts tests/directProjectTurn.test.tsx tests/directProjectTurnStatus.test.ts tests/directThreadChat.test.ts tests/chatComposerAttachmentCap.test.tsx`、`tests/appSurface.test.ts`(213 passed / 9 skipped)、`npx tsc -p tsconfig.json --noEmit`、`eslint`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全绿。真实客户端观感未复核。
## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna
- 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra`、`fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`。
- 决策(范围):本次只改目录初始值的来源,保持既有格式与契约不变 —— 目录字段仍是 `id`/`alias`/`modelId`/`defaultModelId`,后台页面与 admin DTO、`GET /api/llm/models` 形状、客户端 `select_game_creator_model` 的标识校验都不动,因此没有不兼容变更。
- 决策(初始化):api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求上游 Router 控制面的分组定价列表 `GET {控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 排序生成目录:`modelId` 与 `alias` 用上游原名(不再填“高质量/快速”),`id` 用模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`,因此客户端标识校验无需放宽),全部 enabled,默认项取排序后第一项,并按存量 revision 写回自增。
- 决策(来源选择,2026-09-24 实测后确定):不用管理面模型注册表 `/api/models/`(会带出已下线、没有路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,用管理 token 实测 401)。当日 `group=taonier` 在售 6 个:`deepseek-flash`、`deepseek-v4-pro`、`glm-5.3`、`glm-5.3-flash`、`qwen-image-3.0`、`qwen3.8-flash`。
- 决策(失败关闭与重试):拉取失败、空列表、响应超 1 MiB、缺可解析 revision 或写回失败都只记录 error,不写替代目录;未初始化期间 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`“模型目录未初始化”;只在启动期尝试一次,下一次启动重试,直到目录里有数据。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 起不来。
- 决策(幂等与并发):目录只取决于模型集合(排序后生成),重复同步结果一致;多实例并发启动只有一个写入成功,冲突方重读并校验既有目录可用性。目录只在未初始化时重建,上游变化不自动跟随。
- 决策(存量目录):结构合法的目录不会被自动重建,包括旧版写死的 `quality/fast` —— 需要 owner 在后台改掉,或清空该行后重启重新同步。
- 影响范围:`module-runtime`(`from_upstream_models` + slug 生成,删除写死的 `Default`)、`spacetime-module`(缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`)、`api-server`(启动期同步、上游请求硬化、只校验地址的目标校验、后台 PUT 未初始化门禁)、AGC 客户端(默认模型占位改为 `platform-default`)、AGC 模型弹层 CSS、主规范/后端契约/运维文档。
- 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。
- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。
- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。
@@ -21,6 +21,11 @@
3. `server-rs/crates/api-server/src/app.rs`、`server-rs/crates/api-server/src/modules.rs` 与对应 crate README / 源码
4. `docs/openapi/genarrative-external-v1.openapi.json`(涉及 External v1 时)
外部 MCP 语义工具设计:
1. [外部 OpenAPI 与 API Key 接入方案](../../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md):现役托管 MCP 与 External API 合同。
2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。语义工具按 operation 显式声明 destructive 风险,并汇总各 action;上传确认包含已有元数据更新风险。instructions 与 resources 已同步当前工具选择、调用流程及结果说明;资源 URI 保留,Skill 文档源与下载包共用,线上状态按实际部署核对。
AI 游戏创作 / DirectProject / UI workflow:
1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
@@ -1,7 +1,25 @@
# 踩坑与排障记录
## 同一祖先下的多个项目会各自弹一次 UAC
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
- **原因**:`windows_acl_repair_target`(`src-tauri/src/config.rs`)对 Managed 作用域返回「第一个读取被拒的祖先」——同一祖先下的多个项目解析到**同一个** repair target;而唯一的去重是单次调用内的局部 `attempted_targets`,跨调用、跨线程都没有记忆。启动页一次并发检查 ≤8 个最近项目,就会并发启动同样多次 `powershell -Verb RunAs`。
- **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。
- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *<sid>:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。
- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
> 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。
## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
- **原因**:CommonMark 只认**整行**的围栏(最多 3 个空格缩进)。模型经常把 ``` 直接粘在上一行末尾,那个 ``` 退化成行内文本:开场围栏不成立(后面的正文被当成代码)、收场围栏不生效(代码块不闭合,吞掉剩余内容)。`ChatMarkdownMessage` 原先只做空行压缩,没有这一步归一化。
- **处理(现行口径)**:`normalizeMarkdownFences` 在解析前把「围栏前是非空白字符、围栏到行尾只剩语言标识(可空)」的行拆成两行。判据刻意收窄:整行 / 缩进围栏、行内代码(单个反引号)、代码里出现的 ``` (`const s = "```";`)、引用块 / 列表项开头的合法围栏(`> ```js`、`- ```js`)以及同一行里出现第二段围栏串的行内代码(``文本 ```x``` ``)都不动——后三者拆开只会把围栏从引用块 / 列表项里挪出来,或者凭空造出一个开场围栏。归一化对 `preserveBlankLines`(文件预览)同样生效。
- **同时**:块级 `pre` 与块内 `code` 都要给 `whitespace-pre-wrap` + `break-words`(两处各写过 `white-space`,只改一处不换行),否则窄面板里长行会把消息拉宽、顶出横向滚动条。
- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx`(去掉归一化或换行类名即红);真实 Chromium 夹具里长代码行 `horizontalOverflow: false`、粘住的开场 / 收场围栏都渲染成正确结构。
## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
- **现象**:AGC DirectProject 对话区底部那条「陶泥儿正在处理 / 已耗时 12.4秒」的状态条,小数位一秒才动一格,看着像读数卡住;同一屏里工具卡片的耗时与资源生成侧栏的读秒都在正常走 0.1 秒,只有这一处不动。
@@ -5966,3 +5984,19 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。
- **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。
- **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。
## 2026-09-24 DirectProject 失败说明显示在用户消息之上、下一条消息看起来"没报错"
- **现象**:连发几条消息,每条都在连接阶段失败(执行器版本未通过验收)时,界面上"错误出现在自己消息的上面",上一轮底下显示"本轮结束于 <本轮结束时刻> · 耗时 15.6秒",自己这条底下显示"耗时 0.0秒";再发一条,失败说明落进更早的分区,用户以为这条没有报错。
- **原因**:① 本轮的开口用户条目(`item_completed`)原来在 app-server `turn/start` 应答之后才下发,连接阶段失败走不到那一步 → 事件流里只有逻辑回合的一对事件,没有开口条目;② 前端 `buildDirectChatTurns` 按条目顺序分回合,失败说明(assistant 条目)只能挂在"当前回合"(上一轮)末尾;③ 本地乐观气泡被排在所有正式条目之后,于是自成一轮(无边界 → `Math.max(endedAt, startedAt)` 兜底出 0.0 秒),上一轮则借用了本轮的终点(15.6 秒)。另一条独立漏洞:reducer 的收口早退(`!turnRunning && live 为空`)会整条吞掉"订阅重建只回放生命周期锚点"时那条失败说明。
- **处理(现行口径)**:开口用户条目的发点提前到"接单 + 落盘成功、起 codex 之前"(`emit_direct_thread_user_item`),线上仍只有一处下发;回合归属改成按身份(失败说明带 `turnUserItemId`,同一身份的条目永远同一轮),本地气泡按身份挂回自己的回合;收口早退改为"说明还没写进界面就不早退"(只补说明与终点,不重开回合、不抬高冻结终点)。
- **排查提示**:先分清两层 —— 逻辑回合的 `turn.started` / `turn.completed`(Thread Manager,一定有、成对)vs app-server 协议的 `turn/start` 请求(连接拿到之后才发)。"失败说明挂错回合"永远先看这条顺序,不要先怀疑事件丢了。
- **验证**:宿主 `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`。
## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。
- **原因**:本地乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新 2026-09-24)。用户气泡的唯一来源是宿主下发的开口条目(发点=接单成立 + 用户条目落盘成功 + 起 codex 之前)。删它的收益是"回合归属只认身份"不再需要给本地消息一份同名身份,投影也少一个展示态(`awaiting-start`)。
- **排查提示**:窗口期不要拿"有没有本地气泡"当发送成功的证据;证据是 `invoke` 返回 `Ok`(接单成立)与随后到达的 `turn.started` / 开口条目。显示时间与耗时也全以宿主事件为准:起点 `turn.started.at`、终点 `turn.completed.at`;重进项目读回来的历史回合两边都空,整条「本轮结束于 … 」直接隐藏(不再出现 0.0 秒)。
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts`、`.../chat/controller/useDirectProjectChatController.ts`、`.../chat/components/DirectProjectConversation/DirectProjectTurn.tsx`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。