From 1e7ea51368f15ab421988efa64f841734308b2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 20:46:53 +0800 Subject: [PATCH 01/28] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=E5=85=A5=E9=98=9F=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97=E5=BD=92?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E8=AE=BE=E8=AE=A1=E5=AE=9A=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR:命令=入队、放行归 Thread Manager、待发消息队列作为运行态事件归宿主 - 新增实施计划:五步落地顺序、标识符映射表、每步不变式与验收证据 - CONTEXT.md:接单/拒单词条替换为入队/入队失败/放行,新增待发消息队列与待发消息词条 - docs/README.md 补两条索引;decision-log.md 追加同日决策记录 --- CONTEXT.md | 30 ++-- docs/README.md | 2 + ...命令入队化与待发消息队列归宿主-2026-09-24.md | 139 +++++++++++++++++ .../shared-memory/decision-log.md | 33 ++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 144 ++++++++++++++++++ 5 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md create mode 100644 docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md diff --git a/CONTEXT.md b/CONTEXT.md index c22344ea0..00f7c178e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI ## 项目开发对话(DirectProject) **DirectProject 专属聊天模块**: -AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 +AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。 _Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源 **项目工作台布局**: @@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运 _Avoid_: 进度通知、快照轮询、第二套历史 **逻辑回合**: -Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 +Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。 _Avoid_: Codex 原生回合、原生日志、进程生命周期 -**接单**: -把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。 -_Avoid_: 发送成功、命令调用、接口返回 +**待发消息队列**: +Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。 +_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录 -**拒单**: -接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。 +**待发消息**: +已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。 +_Avoid_: 回合、在途回合、草稿 + +**入队**: +把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。 +_Avoid_: 发送成功、已经开跑、回合成立 + +**入队失败**: +入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。 _Avoid_: 回合失败、执行失败、失败事件 +**放行**: +Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。 +_Avoid_: 前端放行、定时轮询、放行失败 + **在途回合**: -界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。 -_Avoid_: 运行中回合、乐观锁、发送队列 +界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。 +_Avoid_: 运行中回合、乐观锁、前端发送队列 **聊天投影**: 把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 diff --git a/docs/README.md b/docs/README.md index 20a519f46..e183cb872 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,8 @@ - [引用候选由宿主注入](./adr/【ADR】引用候选由宿主注入-2026-09-22.md):引用输入区只接受宿主注入的引用 provider,素材选择面板独立成组件,附件芯片成为本轮附件唯一事实源。 - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 +- [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 +- [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md new file mode 100644 index 000000000..d4e741723 --- /dev/null +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -0,0 +1,139 @@ +# 【ADR】DirectProject命令入队化与待发消息队列归宿主 + +状态:已接受(2026-09-24 设计定稿;**尚未落地**,实施顺序与验收见 +[`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) + +## 背景 + +待发消息队列今天是纯前端状态:回合运行中用户再发送就进本地 FIFO +(`chat/components/DirectProjectComposer/chatComposerQueue.ts`),回合终态事件到达后由**恰好开着的那个窗口**放行队首 +(`useDirectProjectChatController.ts` 的完成计数 effect 与 `startTurn` 的 `finally`)。三个问题: + +1. 队列是这条对话里唯一没有宿主持有者的事实:另一个窗口、另一个订阅者,或只是离开工作台再回来,都看不到已经排了什么队。 +2. 放行落在"哪个窗口恰好开着"上。队列一旦共享(本 ADR 要做的),两个窗口都会去放行队首,必然双发。 +3. 命令边界今天写的是"接单":校验通过就登记占用、落盘用户条目、起整轮。但用户按下发送时想要的是"这条消息会被依次处理"—— + 命令的成功含义与用户意图之间隔着一次长度未定义的等待。 + +前置口径:`【ADR】DirectProject命令接单化-2026-09-23` 已把逻辑回合收归 Thread Manager,并在 §8 留了 TODO +「以后这条队列挪到 Rust 端,落点就是 Thread Manager 的接单动作」,同时把 Rust 端发送队列列进"明确不做"。 +本 ADR 就是那条 TODO 的收口,并顺带收掉两处已经没有现役价值的实现。 + +## 决策 + +### 1. 词表:入队 / 入队失败 / 放行 + +- 命令边界的成功与失败改叫 **入队 / 入队失败**;「接单」「拒单」两个词退役,不再出现在文档、注释、标识符与测试名里。 +- 旧「接单」在语义上的角色(这一轮真正成立的那一刻)改叫 **放行**。 +- 因此旧句「接单成立 ⇔ 事件流里有开始有结束」要改写成「**放行成立** ⇔ 事件流里有开始有结束」。这不是换词而是角色搬家: + 逐处改写时按角色判,不做字面替换(命令边界→入队/入队失败;回合成立→放行;检查归属→入队时的检查)。 + +### 2. 命令 = 入队 + +`chat_with_game_creator_direct_codex` 改义改名(建议 `enqueue_direct_codex_turn`),主体是今天"接单前"那条链**原样**跑完: +`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备;通过后**只入队**—— +追加 `queue.enqueued`,不登记占用、不落盘用户条目、不发回合事件、不起 codex。 +任何一步失败就是**入队失败**,走命令返回的 typed 载荷(`DirectTurnRejection` → `DirectTurnEnqueueFailure`),用户就在现场。 + +入队按 `clientTurnId` 幂等,判重范围是"在队 ∪ 正在跑的那一轮";重复入队返回同一次成功,不再是并发拒单。 + +### 3. 队列归 Thread Manager + +- 每个 thread(线程身份就是项目规范路径)一条 FIFO;本期只支持**按顺序追加**与**按身份移除**,不做重排、优先级、编辑。 +- 队列状态**就是事件列表本身**:`queue.enqueued` 在条目仍在队期间不可回收,离开队列(取消或放行)时转为可回收并被既有规则回收。 + 不新增第二张队列表;`subscribe` 的 live-set bootstrap 因此天然把当前队列交给新订阅者——这就是"加入者看到的那几条"。 +- 上限 5 只数**在队条目**(不数正在跑的那一轮),由 Rust 持有;满队时入队失败并给出既有提示文案。 + +### 4. 线上形状 + +- `queue.enqueued { clientTurnId, userItem, creationType?, at }`:`userItem` 是 canonical 用户条目,前端据此派生 chip 文案, + Rust 不渲染、不裁成展示形状。事件**不带** prompt——prompt 是入队检查的产物,只留在宿主的队列条目里。 +- `queue.removed { clientTurnId, reason }`:`reason` 是 ts-rs 导出的 **typed 枚举**(`cancelled | dispatched`),永不用字符串, + 形状与 `turn.completed{status, failure?}` 同构。 +- 两条事件与其它运行态事件同一条流、同一个 reducer。 + +### 5. 放行 = 旧「接单」的语义角色 + +Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登记占用 → 发 `turn.started` → 落盘用户条目 → 下发用户条目 → 起整轮, +并在同一临界区写 `queue.removed{ dispatched }`(chip 消失与气泡出现在同一批 consume 里,中间没有空窗)。 + +**放行不重跑任何检查,也不存在"放行失败"这种状态**:放行之后的一切失败都是**回合失败**,走既有 `turn.completed.failure` 通道; +不新增任何失败通道,也不为放行补拒单出口。 + +### 6. 放行的触发与监护顺序 + +- 唯一放行点:回合任务收尾之后的 `kick`(正常 / 失败 / 中止三条路径共用,外加一个 drop 守卫盖 panic),幂等,并且在临界区里原子认领队首。 +- 入队时也踢一脚:「队列非空 + 线程空闲」是合法状态,对应今天的"直接发送"。 +- 入队**不取** `DirectTaonierActiveInvocationGuard`:它必须整轮持有(它是这一轮的调用身份,付费美术、执行会话、MCP、校验、 + 上下文预取都靠它把工作归属到自己那一轮),入队若取它等于"回合运行中不能入队"。入队路径的并发由工程准备自身的项目写锁与队列兜。 + +### 7. 渲染侧 + +前端不再持有队列副本:chip 只由事件投影(入队被拒时只由命令返回值给反馈,保留既有提示文案与"不丢草稿"行为); +忙态只由事件投影加「队列非空」指示。排队消息在放行前**不写** `project.jsonl`,用户气泡仍然只来自宿主条目("落盘即放行"不变)。 + +### 8. 作用域与寿命 + +每项目一条、全进程共享:A 窗口排队 B 窗口可见可取消;切项目、离开工作台、关窗口都不影响队列继续放行;进程结束队列消失。 +**不做跨进程持久化**。 + +### 9. 顺带退役 + +- `--direct-codex-chat` 整个退役(解析、派发,以及只服务它的 `run_direct_game_creator_turn_at` 一对函数)。 + 它的历史用途只有一个:手工生产验证夹具 `scripts/direct-execution-production-fixture.mjs`(PR #439 引入,不在 CI、 + 没有 npm 脚本或 harness 注册、没有任何测试钉它,唯一硬依赖是进程退出码)。没有产品入口价值,也不该在入队化之后 + 成为第二条直接起回合的路径;夹具脚本一并退役。 +- CLI 一退,`DirectTurnError::TurnAlreadyRunning` 的两个生产点(调用身份守卫、占用登记)都没有调用方, + 它连同前端"同一轮消息仍在处理中"文案、专属分支与测试一起删。 +- `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 + (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:第二步让那五个读者 + 改读 Thread Manager 的活动回合身份,守卫连同它的测试与 60 秒卡死兜底一起删。删之前必须确认取消路径的无条件终态 + 仍然解得开"回合卡死"(那次兜底来自真实事故 `d833ca9d3`)。 + +## 备选方案与取舍 + +1. **状态归宿主、放行留前端**(再用一条 `claim` 命令做 CAS 认领):前端机器原样保留,但队列的继续推进依赖至少一个窗口活着, + 且"谁去认领"要靠竞态解决——正是要消掉的东西。作废。 +2. **入队只做形状校验、把前置检查留到放行**:会造出"放行期拒单"——一个没有调用方在等的失败,得为它发明新通道; + 而且"首个回合还在建工程、第二条已经排队"这类合法流程会被入队误拒。作废(检查跟着入队走)。 +3. **一条 `queue.changed{items:[…]}` 快照事件**代替两条细粒度事件:reducer 更傻,但每次变更搬全量、与既有细粒度事件风格不一致。不选。 +4. **`reason` 用字符串**:前端只能猜、无法穷举、无法在类型层穷尽分支。不选,用 typed 枚举。 +5. **保留 CLI**:省掉夹具改写,但等于为手工验证工具长期保留第二条直接起回合的路径,与"命令只有入队一个入口"冲突。不选。 + +## 影响与代价 + +- **入队即写盘**:引用 UI 设计文档的条目在 prompt 投影时会生成 `ui/generated-.js`(`ui_editor/persistence.rs`); + 从队列里取消不撤该文件。 +- **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 +- **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 +- **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 + `turnBusyRef`/`beginTurnBusy`/`endTurnBusy`、`queueSequenceRef`、`queuedTurns*`、排队条目那部分 `pendingRunAnalyticsRef` + 与 `chatComposerQueue.ts` 的队列逻辑整体退役;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞; + 埋点句柄改由宿主在放行时开。 +- **词表切换是一次性跨文档动作**:仓库里「接单/拒单」共 384 处,并非全属同一个域 + (`features/agent-runtime` 的"拒单文案"属另一个域,改成"请求被拒";`单测` 这类是假阳性)。 + 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 +- **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 + 它不在 CI,损失的是排障时的一次性手段,不是门禁。 +- **埋点**:attempt id 只用于宿主内存里的候选配对与前端结算(同 `session_id` 的 `Settle` 才提交), + 前端不再需要为排队条目提前持有它;平台会话代际翻转的 `discard` 判定仍是渲染侧职责,落地时逐条核对。 + +## 明确不做 + +- 不做跨进程持久化(不在入队时写 `project.jsonl`:那会在历史里留下一条永远不会跑的假消息,破坏"用户气泡只来自宿主条目")。 +- 不做重排、优先级、编辑待发条目;也不做"队列挂起 / 继续"状态——入队化之后队列里不存在会被拒的条目。 +- 不给放行新增失败通道,不为"放行不重跑检查"补兜底。 +- 不保留 CLI,也不为夹具保留别名或兼容入口。 + +## 落地时要同步的文档与注释 + +- `CONTEXT.md`:词条(待发消息队列 / 待发消息 / 入队 / 入队失败 / 放行 / 逻辑回合 / 在途回合)。 +- `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`:§8 的 TODO 与"明确不做:Rust 端发送队列"、§7 的"命令在飞"措辞、 + CLI 保持 await 的分工,全部改为入队化口径(正文保留,顶部加词表注记)。 +- `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`:事件协议一节补两个队列事件与放行时序。 +- `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`:第 4 步的队列 TODO 指向本 ADR。 +- `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、 + `docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md`:删掉对 CLI 入口的承诺 + (最后那处现在写在"范围外(保留)"里,口径反转)。 +- `docs/project-memory/shared-memory/decision-log.md`:追加本决定,并修正"CLI 保持 await"那条。 +- 代码注释:`agent/direct_runtime/user_input.rs` 模块注释里的 CLI 分工一段删掉;`direct_turn_accept.rs`、 + `direct_thread_manager.rs`、`useDirectProjectChatController.ts` 的旧措辞与 TODO 一并改。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 53b75ec40..f8304d518 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9578,3 +9578,36 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 + +## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 + +- 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 + (这一轮真正成立的那一刻)改叫「放行」。所以旧句"接单成立 ⇔ 事件流里有开始有结束"改成"放行成立 ⇔ …", + 逐处按角色改、不做字面替换;`features/agent-runtime` 的"拒单文案"属另一个域,改"请求被拒"。 +- 决策(命令 = 入队):`chat_with_game_creator_direct_codex` 改义改名(暂定 `enqueue_direct_codex_turn`), + 把今天"接单前"那条检查链原样跑完(身份 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备), + 通过后只入队:不登记占用、不落盘用户条目、不发回合事件、不起 codex;失败走命令返回的 typed 载荷 + (`DirectTurnRejection` → `DirectTurnEnqueueFailure`)。入队按 `clientTurnId` 幂等(判重范围 = 在队 ∪ 在跑)。 +- 决策(队列归 Thread Manager):每项目一条 FIFO,只支持按顺序追加与按身份移除;队列的成员与顺序就是事件列表本身 + (`queue.enqueued` 在队期间不可回收、离开队列后可回收),`subscribe` 的 live-set bootstrap 因此天然把当前队列 + 交给中途加入的订阅者;上限 5 只数在队条目,由 Rust 持有。 +- 决策(线上形状):`queue.enqueued{clientTurnId,userItem,creationType?,at}` + `queue.removed{clientTurnId,reason}`, + `reason` 是 ts-rs 导出的 typed 枚举(`cancelled|dispatched`),不用字符串;事件不带 prompt—— + prompt 是入队检查的产物,只留在宿主的队列条目里。 +- 决策(放行):Thread Manager 在回合收口之后原子地「取队首 → 登记占用 → `turn.started` + `queue.removed{dispatched}`」, + 再落盘用户条目、下发用户条目、起整轮。**放行不重跑检查、不存在放行失败**,放行之后的一切失败都是回合失败, + 走既有 `turn.completed.failure`,不新增通道。kick 点 = 回合任务收尾(含 drop 守卫盖 panic)+ 中止路径 + 入队之后, + 幂等且在临界区里认领队首;入队不取 `DirectTaonierActiveInvocationGuard`(它必须整轮持有,是这一轮的调用身份)。 +- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿"); + 埋点句柄改由宿主在放行时开。 +- 决策(顺带退役):`--direct-codex-chat` 整个退役——它唯一的实际消费者是手工夹具 + `scripts/direct-execution-production-fixture.mjs`(PR #439 引入、不在 CI、无 npm/harness 注册、无测试钉它), + 夹具一并退役;`TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"分支随之删除。 + `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, + CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 + (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 +- 代价:入队即写盘(引用 UI 设计文档的条目会生成 `ui/generated-.js`,取消不撤);检查是时间点事实、 + 放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化; + 失去"真实二进制驱动执行层生产验证"这条手工路径。 +- 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、 + `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md new file mode 100644 index 000000000..150ef125a --- /dev/null +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -0,0 +1,144 @@ +# DirectProject 命令入队化与待发消息队列归宿主实施计划 + +更新时间:`2026-09-24` + +状态:**待实施**(设计已定稿,尚未开工) + +设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 +本文件只排实施顺序、不变式与验收,不重复设计理由。 + +## 第 0 步:词表切换(与代码同批,不单独提交) + +「接单 / 拒单」退役,改成「入队 / 入队失败 / 放行」。逐处按角色改,**不做字面替换**: + +| 旧写法 | 新写法 | +| --- | --- | +| 命令的成功 / 失败(接单 / 拒单) | 入队 / 入队失败 | +| 这一轮真正成立的那一刻(接单) | 放行 | +| 接单前的检查 | 入队时的检查 | +| 接单成立 ⇔ 事件流里有开始有结束 | **放行成立** ⇔ 事件流里有开始有结束 | +| 接单后的失败都是回合失败 | **放行后**的失败都是回合失败 | + +三类出现点必须分开处理(全仓 384 处): + +1. DirectProject 域(文档、注释、标识符、测试名):按上表改。主要落点 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、 + `docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、 + `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`、`CONTEXT.md`、 + `agent/direct_runtime/user_input.rs`、`agent/direct_turn_accept.rs`、`agent/direct_turn_error.rs`、`agent/direct_thread_manager.rs`、 + `chat/controller/useDirectProjectChatController.ts`、`chat/conversation/directTurnPresentation.ts`、`tests/appSurface/chat-composer.suite.ts`。 +2. `features/agent-runtime` 域的「拒单文案」(`model.ts`、`tests/agentRuntimeModel.test.ts`):那里没有队列, + 改成「请求被拒 / 拒绝」,不要写成「入队失败」。 +3. 假阳性:`单测` 这类词不动(如 `server-rs/crates/api-server/src/editor_project.rs`)。 + +标识符同批改(映射表): + +| 旧 | 新 | +| --- | --- | +| `chat_with_game_creator_direct_codex` | `enqueue_direct_codex_turn` | +| `DirectTurnRejection`(ts-rs 导出) | `DirectTurnEnqueueFailure` | +| 前端 `readDirectTurnRejection` / `directTurnRejectionNotice*` | `readDirectTurnEnqueueFailure` / `directTurnEnqueueFailureNotice*` | +| Rust `direct_turn_rejection` | `direct_turn_enqueue_failure` | +| `DirectTurnReservation::accept` | `DirectTurnReservation::start` | +| `accept_direct_thread_turn` / `DirectThreadManager::accept_turn` | `start_direct_thread_turn` / `start_turn` | +| `agent/direct_turn_accept.rs` | `agent/direct_turn_dispatch.rs` | +| `DirectTurnError::TurnAlreadyRunning` | 删除(第 4 步判据确认零调用方后) | + +两份已接受的 ADR 保留正文与文件名,只在顶部加一行词表注记,避免正文里的旧词变成假命题。 + +验收:`rg -n "接单|拒单"` 在 DirectProject 域与 `agent-runtime` 域均为 0;生成绑定重跑(`cargo test export_bindings`)后 +`git diff` 只剩映射表内的改动。 + +## 第 1 步:命令 = 入队(Rust) + +改动点: + +- `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 + **入队半**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。**放行半**(第 2 步)从占用登记起。 +- 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 + `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 +- `agent/direct_thread_manager.rs`: + - `MAX_PENDING_DIRECT_TURNS = 5` 落在 Rust,只数在队条目;满队 → typed 入队失败。 + - 队列的成员与顺序**就是事件列表本身**:宿主侧产物挂在对应的 `queue.enqueued` 事件上(`StoredEvent` 增加一个非序列化的 + 可选产物字段),不另建队列表。 + - `observe_event`:`queue.enqueued` 在队期间**不可回收**;`queue.removed` 可回收,并把同 `clientTurnId` 的 enqueued 标记为可回收。 + `is_bootstrap_event` 不改——live-set bootstrap 因此自动把当前队列交给新订阅者。 + - 入队幂等:判重范围「在队 ∪ 正在跑的那一轮」,重复入队返回同一次成功。 + - `remove_direct_project_pending_turn(project_path, client_turn_id)`:typed 结果枚举 `Removed | AlreadyDispatched | NotFound`, + 在临界区里判「是否仍未被认领」。 +- 线上形状:`DirectThreadEvent::QueueEnqueued { client_turn_id, user_item, creation_type?, at }`(`queue.enqueued`)、 + `QueueRemoved { client_turn_id, reason: DirectQueueRemovalReason }`(`queue.removed`), + `DirectQueueRemovalReason` 是 ts-rs 导出的 typed 枚举 `cancelled | dispatched`。 + +不变式:入队不登记占用、不落盘、不发回合事件、不起 codex;入队失败不写用户条目、不产生事件。 + +## 第 2 步:放行与 kick + +- `start_direct_thread_turn`:同一个临界区里「取队首 → 占用登记 → `turn.started` + `queue.removed{dispatched}`」; + 之后落盘用户条目 → 下发用户条目 → spawn 整轮(顺序与今天的接单后半段一致,`accept` 必须早于落盘与 `turn/start`)。 +- `kick_direct_queue_dispatch(thread_id)`:幂等;在临界区里判「无占用 + 队首存在 + 未被认领」,认领后 spawn 放行任务。 + 调用点三个:回合任务收尾(正常 / 失败共用)、中止路径、入队之后。 +- panic 兜底:回合任务里的一个 drop 守卫负责踢一脚,保证任务 panic 或 future 被丢弃时队列不会永久停住。 +- 入队不取 `DirectTaonierActiveInvocationGuard`;该守卫继续由整轮持有。 +- 不变式:放行不重跑检查、没有放行失败;放行之后的一切失败都走 `turn.completed.failure`。 + +验收(Rust 单测):放行原子性(`queue.removed{dispatched}` 与 `turn.started` 同批、无中间窗口); +kick 幂等(并发两次只认领一次);队首在放行后被移除、remove 对已放行条目返回 `AlreadyDispatched`; +回合失败 / 中止后队列继续放行下一条;任务 panic 后队列仍能继续;多订阅者游标各自独立时 bootstrap 仍重建完整队列。 + +## 第 3 步:前端收口 + +退役: + +- `chatComposerQueue.ts` 的 `enqueueChatTurn` / `dequeueChatTurn` / `removeQueuedChatTurn` / `isChatTurnQueueFull` / + `MAX_QUEUED_CHAT_TURNS`(提示文案 `chatQueueFullNotice` 保留,改由 Rust 的 typed 入队失败驱动)。 +- controller 的 `queuedTurns` / `queuedTurnsRef` / `queueSequenceRef` / `completionPendingRef` / `handledCompletedTurnCountRef` / + `busyBaselineTurnCountRef` / `dispatchNextQueuedTurn` / `beginTurnBusy` / `endTurnBusy` / `turnBusyRef` 与排序用的计数 effect。 +- 排队条目那部分 `pendingRunAnalyticsRef` 与 `beginDirectRunAnalytics` 的调用时序(埋点句柄改由宿主在放行时开)。 + +保留与改写: + +- chip 由运行态事件的投影驱动(新增 pending 列表投影与两条队列事件的 reducer 分支); + chip 文案仍用现成的派生(`directCodexContentToPromptText` + `resourceLabelResolver`),只是输入换成事件里的 `userItem`。 +- 取消 chip 改调 `remove_direct_project_pending_turn`;入队失败只由命令返回值驱动提示(保留"不丢草稿"行为)。 +- 忙态 = 事件投影 + 「队列非空」指示;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞。 +- 写权限门 `ensureConversationWriteAllowed` 留在入队之前(确认框必须在用户在场时弹)。 + +验收:`tests/directThreadChat.test.ts` 补队列事件投影用例;`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / +放行三组用例改成新语义;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 + +## 第 4 步:CLI 与夹具退役 + +- `cli.rs`:删 `CliCommand::DirectCodexChat` 变体、`project_path_mut` 分支(`cli.rs:181`)与派发分支(`cli.rs:899-935`)。 +- `agent/direct_runtime/mod.rs`:删 `run_direct_game_creator_turn_at` 与 `run_direct_game_creator_turn_at_with_creation_type` + (各自只有彼此与 CLI 一个调用方)。 +- 删 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(CLI 的唯一消费者)。 +- 删 `DirectTurnError::TurnAlreadyRunning`(两个生产点都没了)与前端"同一轮消息仍在处理中"文案、专属分支、 + `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- 文档同步:09-22 里程碑把 `--direct-codex-chat` 从"范围外(保留)"改成退役项;两份 Direct 技术方案的 CLI 承诺删掉; + 09-23 ADR 与 `decision-log.md` 里"CLI 保持 await"的口径改掉。 + +验收:`rg -n -- "--direct-codex-chat"` 与 `rg -n "run_direct_game_creator_turn_at"` 零命中; +`cargo check --tests` 无新增 `dead_code` 告警;`check-config.mjs` 与 `.gitea/workflows/project-ci.yml` 不受影响(已核实无引用)。 + +## 第 5 步:守卫清理(CLI 退役之后) + +- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成、 + `agent/direct_validation.rs`、`agent/direct_project_context.rs`)改读 Thread Manager 的活动回合身份 + (新增一个按项目路径取 `active_turn.turn_id` 的只读入口)。 +- 删 `DirectTaonierActiveInvocationGuard` 与 `release_stale_direct_taonier_active_invocation` 及其测试 + (`direct_runtime/mod.rs` 的守卫用例、`direct_project_context.rs`、`direct_tool_bridge.rs`、`direct_tools_mcp.rs`、 + `user_input.rs`、`codex_app_server/mod.rs` 的集成用例),取消路径改为直接走无条件的 `complete_direct_thread_turn("aborted")`。 +- 前置判据:补一条测试证明"回合任务被 park / 泄漏时,取消仍能清空占用并让队列继续放行"——那次 60 秒兜底来自真实事故 + (`d833ca9d3`),删它必须有等价保证。 + +## 验收与证据 + +- Rust:第 1、2、5 步各自的单测;`cargo test` 定向 + `cargo check --tests` 无新增告警。 +- Node:`npx vitest run tests/appSurface.test.ts`、`npm --workspace apps/ai-game-creator-shell run typecheck`。 +- 端到端(`chat-composer.suite.ts`):回合运行中入队两条 → 取消一条 → 终态后只放行剩下那条; + 入队只发生一次 IPC、没有第二次发送命令;满队提示;入队失败时草稿不丢。 +- 手工:两个窗口看同一项目(A 排队 B 可见可取消);离开工作台再回来队列仍在并继续放行; + `kill -9` 后重进队列消失(与 ADR 的已知边界一致)。 +- 全仓:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +- 不涉及 SpacetimeDB schema,不需要 `npm run check:spacetime-schema`。 From faf85837bdca7eb702ef9688067c5781ac0a3f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 20:58:08 +0800 Subject: [PATCH 02/28] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=EF=BC=9A=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F?= =?UTF-8?q?=E5=88=97=E7=9A=84=E5=85=A5=E9=98=9F/=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=8E=E9=98=9F=E5=88=97=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `DirectThreadEvent::QueueEnqueued`(`queue.enqueued`,带 canonical 用户条目与可选 `creationType`,不带 prompt)与 `QueueRemoved`(`queue.removed`,`reason` 为 typed 枚举 `cancelled | dispatched`) - 新增 typed 枚举 `DirectQueueRemovalReason` 与 `DirectQueueRemovalOutcome`,并重跑 ts-rs 绑定 - 新增 `agent/direct_thread_queue.rs`:`PendingDirectTurn`(入队时冻结 canonical 形状与 prompt)、上限 `MAX_PENDING_DIRECT_TURNS = 5`、`EnqueueOutcome` / `EnqueueRejection` - Thread Manager 的 `observe_event` 认队列事件:在队期间的 `queue.enqueued` 不可回收,`queue.removed` 把它转成可回收,新订阅者的 bootstrap 因此天然看得见当前队列 - `DirectCodexUserItem` 及其子类型补 `PartialEq`,`DirectThreadEvent` 不再需要 `Eq` --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_user_item/model.rs | 12 +- .../src/agent/direct_thread_manager.rs | 17 ++ .../src/agent/direct_thread_queue.rs | 200 ++++++++++++++++++ .../src-tauri/src/agent/direct_thread_wire.rs | 166 ++++++++++++++- .../generated/DirectQueueRemovalOutcome.ts | 9 + .../generated/DirectQueueRemovalReason.ts | 9 + .../chat/generated/DirectThreadEvent.ts | 26 ++- 8 files changed, 429 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 347ec3139..781aad21d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -29,6 +29,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_queue; mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; @@ -65,6 +66,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_queue::*; pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b65f25ba4..2b537d5f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserItem { @@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem { Message(DirectCodexUserMessageItem), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserMessageItem { @@ -19,14 +19,14 @@ pub(crate) struct DirectCodexUserMessageItem { pub(crate) id: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserRole { User, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexUserContentPart { @@ -43,7 +43,7 @@ pub(crate) enum DirectCodexUserContentPart { AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserAttachmentReferencePart { @@ -55,7 +55,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { pub(crate) status: String, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2b624df55..2e66f086c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -428,6 +428,14 @@ impl DirectThreadManager { true } DirectThreadEvent::ItemDelta { .. } => true, + // 待发消息在队期间不可回收:队列的成员与顺序**就是**这条事件本身 + // (见 `direct_thread_queue`)。bootstrap 因此天然看得见当前队列。 + DirectThreadEvent::QueueEnqueued { .. } => false, + // 离开队列(取消 / 放行)才把同一条待发消息的入队事件一起转成可回收。 + DirectThreadEvent::QueueRemoved { client_turn_id, .. } => { + Self::mark_queue_events_cleanable(thread, client_turn_id); + true + } } } @@ -471,6 +479,15 @@ impl DirectThreadManager { } } + /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 + fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { + for stored in &mut thread.events { + if stored.event.queue_client_turn_id() == Some(client_turn_id) { + stored.cleanable = true; + } + } + } + fn trim_prefix(thread: &mut ThreadState) { let min_cursor = thread .subscribers diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs new file mode 100644 index 000000000..2785c87cb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -0,0 +1,200 @@ +//! DirectProject 待发消息队列的条目与规则。 +//! +//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的 +//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此 +//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。 +//! +//! 这个模块只放三件事:一条待发消息带走什么([`PendingDirectTurn`])、容量规则 +//! ([`MAX_PENDING_DIRECT_TURNS`])、以及它在线上长什么样([`PendingDirectTurn::enqueued_event`])。 +//! 它不碰锁、不碰 Tauri、不写盘:入队检查在命令侧,放行顺序在 Thread Manager。 + +use serde_json::Value; + +use crate::agent::{ + direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, DirectThreadEvent, +}; + +/// 一个项目最多能同时排队的待发消息条数。 +/// +/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回 +/// typed 入队失败。 +pub(crate) const MAX_PENDING_DIRECT_TURNS: usize = 5; + +/// 一条已经通过入队检查、正在等放行的用户消息。 +/// +/// 只在内存里,进程重启即消失(与 ADR 记的边界一致)。它同时是**放行时要用的全部输入**:放行 +/// 没有失败出口,所以检查产物在入队时就地冻结,放行只搬运、不重算。 +#[derive(Clone, Debug)] +pub(crate) struct PendingDirectTurn { + /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + pub(crate) client_turn_id: String, + /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 + pub(crate) user_item: DirectCodexUserItem, + /// canonical 用户条目的 JSON 形状:入队时算好,放行时直接落盘。 + pub(crate) canonical_user_item: Value, + /// 入队检查产出的 prompt:放行不重算。 + pub(crate) prompt: String, + pub(crate) creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + pub(crate) at: u64, +} + +impl PendingDirectTurn { + /// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。 + /// + /// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里 + /// (canonical 形状与 prompt 都是)。 + pub(crate) fn prepare( + client_turn_id: String, + user_item: DirectCodexUserItem, + prompt: String, + creation_type: Option, + at: u64, + ) -> Result { + let canonical_user_item = serde_json::to_value(&user_item)?; + Ok(Self { + client_turn_id, + user_item, + canonical_user_item, + prompt, + creation_type, + at, + }) + } + + /// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 只留在宿主的 + /// 队列条目里,它不是要下发的展示形状)。 + pub(crate) fn enqueued_event(&self) -> DirectThreadEvent { + DirectThreadEvent::queue_enqueued( + self.client_turn_id.clone(), + self.user_item.clone(), + self.creation_type.clone(), + self.at, + ) + } + + /// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。 + pub(crate) fn user_item_id(&self) -> Option { + direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id) + } +} + +/// 入队的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueOutcome { + /// 这次真的排进队尾了。 + Enqueued, + /// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。 + AlreadyKnown, +} + +/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnqueueRejection { + /// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。 + QueueFull, +} + +/// 队列还有没有位置。`pending_count` 只数在队条目。 +pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> { + if pending_count >= MAX_PENDING_DIRECT_TURNS { + return Err(EnqueueRejection::QueueFull); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + "id": id, + })) + .expect("canonical user item") + } + + fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + user_item("生成一个游戏", "direct-codex:turn-1:user"), + "生成一个游戏".to_string(), + creation_type.map(str::to_string), + 1_700_000_000_123, + ) + .expect("prepare pending turn") + } + + /// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物, + /// 不许顺着事件下发。 + #[test] + fn enqueued_event_carries_the_canonical_item_and_no_prompt() { + let event = pending("turn-1", Some("web-game")).enqueued_event(); + let value = serde_json::to_value(&event).expect("serialize queue.enqueued"); + assert_eq!( + value, + json!({ + "type": "queue.enqueued", + "clientTurnId": "turn-1", + "userItem": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "生成一个游戏"}], + "id": "direct-codex:turn-1:user", + }, + "creationType": "web-game", + "at": 1_700_000_000_123u64, + }) + ); + assert!(value.get("prompt").is_none(), "{value}"); + + // 没有创建类型时不写字段,也不补 `null`。 + let bare = serde_json::to_value(pending("turn-2", None).enqueued_event()) + .expect("serialize queue.enqueued without creation type"); + assert!(bare.get("creationType").is_none(), "{bare}"); + + // 事件读得出自己的待发消息身份。 + assert_eq!(event.queue_client_turn_id(), Some("turn-1")); + assert_eq!(event.queue_removal_reason(), None); + } + + /// canonical 形状在入队时就冻结:放行时落盘的就是这一份,不再重算。 + #[test] + fn prepare_freezes_the_canonical_item() { + let turn = pending("turn-1", None); + assert_eq!( + turn.canonical_user_item["id"], + json!("direct-codex:turn-1:user") + ); + assert_eq!( + turn.canonical_user_item, + serde_json::to_value(&turn.user_item).expect("serialize user item") + ); + } + + /// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。 + #[test] + fn user_item_id_derives_from_the_client_turn_id() { + assert_eq!( + pending("turn-1", None).user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + assert_eq!(pending(" ", None).user_item_id(), None); + } + + /// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。 + #[test] + fn capacity_closes_at_five_pending_turns() { + for count in 0..MAX_PENDING_DIRECT_TURNS { + assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置"); + } + assert_eq!( + queue_has_room(MAX_PENDING_DIRECT_TURNS), + Err(EnqueueRejection::QueueFull) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index 952dd3a24..a4649938e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectCodexUserItem; use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; @@ -212,6 +213,33 @@ impl DirectThreadRequestKind { } } +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectQueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经接单并成为回合。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectQueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 @@ -259,7 +287,7 @@ impl DirectTurnFailure { /// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 /// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 /// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectThreadEvent { @@ -321,6 +349,34 @@ pub(crate) enum DirectThreadEvent { #[serde(default)] request_id: Option, }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件不带 prompt:prompt 是入队检查的产物,只留在宿主的队列条目里。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: DirectCodexUserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: DirectQueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, } impl DirectThreadEvent { @@ -417,6 +473,51 @@ impl DirectThreadEvent { Self::Request { kind, request_id } } + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: DirectCodexUserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: DirectQueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 /// /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 @@ -427,6 +528,8 @@ impl DirectThreadEvent { | Self::TurnCompleted { at, .. } | Self::ItemStarted { at, .. } | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, Self::ItemDelta { .. } | Self::Request { .. } => None, } } @@ -456,7 +559,7 @@ impl DirectThreadEvent { } } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadSubscriptionBootstrap { @@ -468,7 +571,7 @@ pub(crate) struct DirectThreadSubscriptionBootstrap { pub(crate) events: Vec, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectThreadConsumeResult { @@ -1466,4 +1569,61 @@ mod tests { .expect("failed turn without failure payload"); assert_eq!(sparse.failure(), None); } + + /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, + /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 + #[test] + fn queue_removed_carries_a_typed_reason() { + let dispatched = DirectThreadEvent::queue_removed( + "turn-1".to_string(), + DirectQueueRemovalReason::Dispatched, + 2_000, + ); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(DirectQueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts new file mode 100644 index 000000000..5a70720f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalOutcome.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 取消一条待发消息的结果。 + */ +export type DirectQueueRemovalOutcome = + | 'removed' + | 'alreadyDispatched' + | 'notFound'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts new file mode 100644 index 000000000..90f8b8247 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条待发消息离开队列的原因。 + * + * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 + * (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 + */ +export type DirectQueueRemovalReason = 'cancelled' | 'dispatched'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index da0136295..630a41e95 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; +import type { DirectQueueRemovalReason } from './DirectQueueRemovalReason'; import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; import type { DirectThreadItem } from './DirectThreadItem'; import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; @@ -84,8 +86,26 @@ export type DirectThreadEvent = kind: DirectThreadDeltaKind; delta: string; } + | { type: 'request'; kind: DirectThreadRequestKind; requestId: string | null } | { - type: 'request'; - kind: DirectThreadRequestKind; - requestId: string | null; + type: 'queue.enqueued'; + /** + * 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + */ + clientTurnId: string; + /** + * canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + */ + userItem: DirectCodexUserItem; + creationType?: string; + /** + * 入队那一刻的宿主毫秒钟。 + */ + at: number; + } + | { + type: 'queue.removed'; + clientTurnId: string; + reason: DirectQueueRemovalReason; + at?: number; }; From 053e0bd93220b118e997ce59edf9f062a81a7cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 21:11:15 +0800 Subject: [PATCH 03/28] =?UTF-8?q?Thread=20Manager=EF=BC=9A=E5=BE=85?= =?UTF-8?q?=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97=E7=9A=84=E5=85=A5?= =?UTF-8?q?=E9=98=9F=20/=20=E5=8F=96=E6=B6=88=20/=20=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E8=AE=A4=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `StoredEvent` 增加非序列化的 `pending` 产物字段:它的有无就是队列成员身份,队列的先后就是事件先后,不另建队列表 - 新增 `enqueue_pending_turn`:按 `clientTurnId` 幂等(判重范围「在队 ∪ 正在跑的那一轮」),容量用 `queue_has_room` 挡在队条目 - 新增 `remove_pending_turn`:typed 结果 `Removed | AlreadyDispatched | NotFound`,在临界区里取走产物并追加 `queue.removed{cancelled}` - 新增 `claim_pending_turn`:同一临界区取队首 → 登记占用 → 追加 `turn.started` 与 `queue.removed{dispatched}`,已有未收口回合或队列为空时返回 `None` - `mark_queue_events_cleanable` 跳过仍挂着产物的条目,保证在队条目永远不会被回收 - 新增薄包装 `enqueue_direct_pending_turn` / `remove_direct_pending_turn` / `claim_direct_pending_turn`,并补 5 条单测覆盖 bootstrap 可见、幂等、容量、放行原子性与三种取消结果 --- .../src/agent/direct_thread_manager.rs | 451 +++++++++++++++++- 1 file changed, 449 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2e66f086c..a83e68baa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -9,7 +9,9 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ + direct_tool_call_now_ms, queue_has_room, DirectQueueRemovalOutcome, DirectQueueRemovalReason, DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, + EnqueueOutcome, EnqueueRejection, PendingDirectTurn, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -28,6 +30,12 @@ struct StoredEvent { seq: u64, bytes: usize, cleanable: bool, + /// 这条事件在队期间挂着的宿主侧产物。 + /// + /// 只有 `queue.enqueued` 会带它,而且**它本身就是队列成员身份**:`Some` = 这条待发消息还在队, + /// 被取走 = 已经离开队列。队列的先后就是事件的先后,所以没有第二张队列表要对齐,也不会出现 + /// "事件说在队、队列表说不在"的中间态(见 `direct_thread_queue`)。 + pending: Option, } #[derive(Clone, Debug)] @@ -75,6 +83,16 @@ pub(crate) struct DirectActiveTurnSnapshot { pub(crate) sequence: u64, } +/// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 +/// +/// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 +/// 重跑任何检查,也就不需要再回到调用方取参数。 +#[derive(Clone, Debug)] +pub(crate) struct DirectDispatchedTurn { + pub(crate) token: String, + pub(crate) pending: PendingDirectTurn, +} + #[derive(Clone, Debug)] struct ThreadState { next_seq: u64, @@ -142,6 +160,16 @@ impl DirectThreadManager { &mut self, thread_id: &str, event: DirectThreadEvent, + ) -> DirectThreadEvent { + self.append_inner(thread_id, event, None) + } + + /// 追加事件。`pending` 用来把宿主侧产物挂到这条事件上(只有入队会传)。 + fn append_inner( + &mut self, + thread_id: &str, + event: DirectThreadEvent, + pending: Option, ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); @@ -156,6 +184,7 @@ impl DirectThreadManager { seq, bytes, cleanable, + pending, }); if let Some(item_id) = event.item_id() { Self::mark_item_events_cleanable(thread, item_id, seq); @@ -331,6 +360,153 @@ impl DirectThreadManager { .is_some_and(|thread| thread.active_turn.is_some()) } + /// 在队的待发消息,队首在前。 + fn pending_turns(thread: &ThreadState) -> Vec<&PendingDirectTurn> { + thread + .events + .iter() + .skip(thread.head) + .filter_map(|stored| stored.pending.as_ref()) + .collect() + } + + /// 入队:把一条已经通过入队检查的待发消息排到队尾。 + /// + /// 幂等按 `clientTurnId`,判重范围是「**在队 ∪ 正在跑的那一轮**」:同一身份重复入队返回 + /// [`EnqueueOutcome::AlreadyKnown`],不排第二条、不发第二条事件。容量只数在队条目。 + fn enqueue_pending_turn( + &mut self, + thread_id: &str, + turn: PendingDirectTurn, + ) -> Result { + { + let thread = self.threads.entry(thread_id.to_string()).or_default(); + let already_known = Self::pending_turns(thread) + .iter() + .any(|pending| pending.client_turn_id == turn.client_turn_id) + || thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == turn.client_turn_id); + if already_known { + return Ok(EnqueueOutcome::AlreadyKnown); + } + queue_has_room(Self::pending_turns(thread).len())?; + } + let event = turn.enqueued_event(); + self.append_inner(thread_id, event, Some(turn)); + Ok(EnqueueOutcome::Enqueued) + } + + /// 取消一条待发消息。typed 结果告诉调用方它到底是被取消、已经放行,还是本来就不在队里。 + /// + /// 判「已经被放行」用的是正在跑的那一轮的回合身份:认领之后就再也按待发消息取消不了了。 + fn remove_pending_turn( + &mut self, + thread_id: &str, + client_turn_id: &str, + at_ms: u64, + ) -> DirectQueueRemovalOutcome { + let removed = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return DirectQueueRemovalOutcome::NotFound; + }; + if thread + .active_turn + .as_ref() + .is_some_and(|active| active.turn_id == client_turn_id) + { + return DirectQueueRemovalOutcome::AlreadyDispatched; + } + let head = thread.head; + let mut removed = false; + for stored in thread.events.iter_mut().skip(head) { + if stored + .pending + .as_ref() + .is_some_and(|pending| pending.client_turn_id == client_turn_id) + { + // 取走产物即离开队列:这一幕在临界区里发生,外面看不到"事件还在、条目已经不在"。 + stored.pending = None; + removed = true; + } + } + removed + }; + if !removed { + return DirectQueueRemovalOutcome::NotFound; + } + self.append( + thread_id, + DirectThreadEvent::queue_removed( + client_turn_id.to_string(), + DirectQueueRemovalReason::Cancelled, + at_ms, + ), + ); + DirectQueueRemovalOutcome::Removed + } + + /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 + /// + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的。这两件事 + /// 都由这里判,调用方(kick)不需要先探一遍再决定——探两次就是两个中间态。 + /// + /// 认领认的是队首,不是调用方点名的某一条:待发消息的顺序就是事件顺序,没有第三种来源。 + fn claim_pending_turn( + &mut self, + thread_id: &str, + started_at_ms: u64, + ) -> Option { + let pending = { + let thread = self.threads.get_mut(thread_id)?; + if thread.active_turn.is_some() { + return None; + } + let head = thread.head; + let stored = thread + .events + .iter_mut() + .skip(head) + .find(|stored| stored.pending.is_some())?; + stored.pending.take().expect("filtered on a pending turn") + }; + let token = Uuid::new_v4().to_string(); + let turn_id = pending.client_turn_id.clone(); + let project_name = std::path::Path::new(thread_id) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string); + if let Some(thread) = self.threads.get_mut(thread_id) { + thread.active_turn = Some(ActiveDirectTurn { + token: token.clone(), + turn_id: turn_id.clone(), + project_name, + started_at: started_at_ms, + // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 + status: "accepted".to_string(), + activity: Some("request-accepted".to_string()), + updated_at: started_at_ms, + sequence: 0, + }); + } + let user_item_id = pending.user_item_id(); + self.append( + thread_id, + DirectThreadEvent::turn_started(started_at_ms) + .with_user_item_id(user_item_id.as_deref()), + ); + self.append( + thread_id, + DirectThreadEvent::queue_removed( + turn_id, + DirectQueueRemovalReason::Dispatched, + started_at_ms, + ), + ); + Some(DirectDispatchedTurn { token, pending }) + } + fn subscriber_ids(&self, thread_id: &str) -> Vec { self.threads .get(thread_id) @@ -395,6 +571,20 @@ impl DirectThreadManager { }) } + /// 当前在队的待发消息身份,队首在前。 + #[cfg(test)] + fn pending_turn_ids(&self, thread_id: &str) -> Vec { + self.threads + .get(thread_id) + .map(|thread| { + Self::pending_turns(thread) + .iter() + .map(|pending| pending.client_turn_id.clone()) + .collect() + }) + .unwrap_or_default() + } + /// 观察一条事件,返回它本身是否可回收。 fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { match event { @@ -482,7 +672,10 @@ impl DirectThreadManager { /// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。 fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) { for stored in &mut thread.events { - if stored.event.queue_client_turn_id() == Some(client_turn_id) { + // 仍挂着产物的条目永远不算可回收:在队条目被回收就等于队列悄悄丢了一条消息。 + if stored.pending.is_none() + && stored.event.queue_client_turn_id() == Some(client_turn_id) + { stored.cleanable = true; } } @@ -601,6 +794,56 @@ pub(crate) fn accept_direct_thread_turn( Ok(()) } +/// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `direct_thread_queue`)。 +pub(crate) fn enqueue_direct_pending_turn( + thread_id: &str, + turn: PendingDirectTurn, +) -> Result { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .enqueue_pending_turn(thread_id, turn) + }; + if matches!(outcome, Ok(EnqueueOutcome::Enqueued)) { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + +/// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 +pub(crate) fn remove_direct_pending_turn( + thread_id: &str, + client_turn_id: &str, +) -> DirectQueueRemovalOutcome { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + }; + if outcome == DirectQueueRemovalOutcome::Removed { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + +/// 认领队首并登记占用(放行的原子步骤,见 [`DirectThreadManager::claim_pending_turn`])。 +/// +/// `None` = 现在不该放行(已有未收口的回合,或队列为空)。 +pub(crate) fn claim_direct_pending_turn(thread_id: &str) -> Option { + let claimed = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + }; + if claimed.is_some() { + notify_direct_thread_subscribers(thread_id); + } + claimed +} + /// 运行时回填某一轮逻辑回合的进度(状态 / 活动 / 序号)。返回是否真的写进去了。 pub(crate) fn update_direct_thread_active_turn( thread_id: &str, @@ -700,7 +943,9 @@ pub(crate) fn consume_direct_thread( #[cfg(test)] mod tests { use super::*; - use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; + use crate::agent::{ + DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind, MAX_PENDING_DIRECT_TURNS, + }; fn message(item_id: &str) -> DirectThreadItem { DirectThreadItem::Message { @@ -1110,4 +1355,206 @@ mod tests { assert_eq!(conflict.as_deref(), Some("turn-1")); } + + fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + format!("消息 {client_turn_id}"), + None, + FIXED_AT_MS, + ) + .expect("prepare pending turn") + } + + fn enqueued_ids(events: &[DirectThreadEvent]) -> Vec<&str> { + events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::QueueEnqueued { client_turn_id, .. } => { + Some(client_turn_id.as_str()) + } + _ => None, + }) + .collect() + } + + /// 队列就是事件列表:新订阅者的 bootstrap 看得见当前队列,取消之后那条就从队列里消失。 + #[test] + fn new_subscribers_see_the_current_queue_until_it_is_removed() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-1", "turn-2"]); + + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::Removed + ); + // 离开队列的事件带 typed 原因:这条是用户取消。 + let events = manager + .consume(&watcher.subscription_id) + .expect("consume removal") + .events; + assert!( + matches!( + events.as_slice(), + [DirectThreadEvent::QueueRemoved { client_turn_id, reason, .. }] + if client_turn_id == "turn-1" && *reason == DirectQueueRemovalReason::Cancelled + ), + "{events:?}" + ); + // 队尾那条仍在,队首那条不再对新订阅者可见。 + let bootstrap = manager.subscribe("thread-1"); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 + #[test] + fn enqueue_is_idempotent_per_client_turn_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue again"), + EnqueueOutcome::AlreadyKnown + ); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-1"]); + + // 正在跑的那一轮同样算已知:它的身份已经在占用里,不该再排一条等放行。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue while running"), + EnqueueOutcome::AlreadyKnown + ); + assert!(manager.pending_turn_ids("thread-1").is_empty()); + } + + /// 容量只数在队条目:正在跑的那一轮不占等待位。 + #[test] + fn capacity_counts_only_pending_turns() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + for index in 0..MAX_PENDING_DIRECT_TURNS { + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"), + EnqueueOutcome::Enqueued + ); + } + assert_eq!( + manager.enqueue_pending_turn("thread-1", pending_turn("turn-overflow")), + Err(EnqueueRejection::QueueFull) + ); + + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-overflow")) + .expect("enqueue after dispatch"), + EnqueueOutcome::Enqueued + ); + } + + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 + #[test] + fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + let claimed = manager + .claim_pending_turn("thread-1", 2_000) + .expect("claim head"); + assert_eq!(claimed.pending.client_turn_id, "turn-1"); + assert_eq!(claimed.pending.prompt, "消息 turn-1"); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + + let events = manager + .consume(&watcher.subscription_id) + .expect("consume dispatch") + .events; + assert!( + matches!( + events.as_slice(), + [ + DirectThreadEvent::TurnStarted { at, .. }, + DirectThreadEvent::QueueRemoved { client_turn_id, reason, at: removed_at }, + ] if *at == Some(2_000) + && client_turn_id == "turn-1" + && *reason == DirectQueueRemovalReason::Dispatched + && *removed_at == Some(2_000) + ), + "{events:?}" + ); + + // 已经有未收口的回合:认领不再放行第二条。 + assert!(manager.claim_pending_turn("thread-1", 2_100).is_none()); + assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); + } + + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 + #[test] + fn removal_tells_cancelled_dispatched_and_unknown_apart() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::Removed + ); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS), + DirectQueueRemovalOutcome::NotFound + ); + + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.remove_pending_turn("thread-1", "turn-2", FIXED_AT_MS), + DirectQueueRemovalOutcome::AlreadyDispatched + ); + } } From 132f96581addb98880164d5ae1a8e8e423dda355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 21:36:22 +0800 Subject: [PATCH 04/28] =?UTF-8?q?DirectProject=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=85=A5=E9=98=9F=E5=8C=96=EF=BC=9A=E6=94=BE=E8=A1=8C=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E3=80=81=E9=98=9F=E5=88=97=E7=A7=BB=E9=99=A4=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B8=8E=E8=AF=8D=E8=A1=A8=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命令改名 enqueue_direct_codex_turn:跑完入队检查后只入队,返回结构化的入队失败载荷 - direct_turn_accept.rs 改为 direct_turn_dispatch.rs:放行占用对象改为接管 Thread Manager 认领好的那一轮 - 新增 kick_direct_queue_dispatch:入队、占用释放与中止路径共用同一个幂等放行点 - 放行之后取调用身份、落盘用户条目、下发条目、跑整轮,失败一律收口成回合失败 - 新增 remove_direct_project_pending_turn 命令,取消一条还没放行的待发消息 - 队列条目补 analytics_attempt_id:埋点身份跟着入队走,放行不重算 - DirectTurnRejection 改名 DirectTurnEnqueueFailure,新增 QueueFull 变体 - 退役 accept_turn / accept_direct_thread_turn,测试改用 accept_for_test 建占用 - 前端与生成绑定同步改名,DirectTurnRejection.ts 换成 DirectTurnEnqueueFailure.ts --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 37 +- .../src/agent/direct_project_context.rs | 26 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 30 +- .../src/agent/direct_runtime/user_input.rs | 328 +++++++--------- .../src/agent/direct_thread_manager.rs | 100 +---- .../src/agent/direct_thread_queue.rs | 5 + .../src-tauri/src/agent/direct_thread_wire.rs | 6 +- .../src-tauri/src/agent/direct_turn_accept.rs | 282 -------------- .../src/agent/direct_turn_dispatch.rs | 357 ++++++++++++++++++ .../src-tauri/src/agent/direct_turn_error.rs | 47 ++- .../src/agent/direct_turn_failure.rs | 2 +- .../src-tauri/src/commands.rs | 23 ++ .../src-tauri/src/main.rs | 3 +- .../src/features/agent-runtime/model.ts | 6 +- .../chat/DirectProjectChatView.tsx | 2 +- .../useDirectProjectChatController.ts | 88 ++--- .../controller/useDirectProjectTurnStatus.ts | 4 +- .../conversation/directCodexConversation.ts | 46 ++- .../chat/conversation/directThreadChat.ts | 4 +- .../conversation/directTurnPresentation.ts | 16 +- .../chat/generated/DirectCodexFailureStage.ts | 2 +- .../chat/generated/DirectModelCallKind.ts | 2 +- .../generated/DirectQueueRemovalReason.ts | 2 +- .../chat/generated/DirectThreadEvent.ts | 2 +- .../chat/generated/DirectTurnDeadline.ts | 2 +- ...jection.ts => DirectTurnEnqueueFailure.ts} | 4 +- .../chat/generated/DirectTurnError.ts | 1 + .../tests/agentRuntimeModel.test.ts | 6 +- .../tests/appSurface/chat-composer.suite.ts | 58 +-- .../tests/appSurface/home.suite.ts | 104 +++-- .../appSurface/project-conversation.suite.ts | 36 +- .../tests/directThreadChat.test.ts | 4 +- .../tests/directTurnPresentation.test.ts | 2 +- 34 files changed, 800 insertions(+), 841 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectTurnRejection.ts => DirectTurnEnqueueFailure.ts} (85%) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 781aad21d..0bde49504 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,7 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; -mod direct_turn_accept; +mod direct_turn_dispatch; mod direct_turn_error; mod direct_turn_failure; mod direct_turn_stream; @@ -71,7 +71,7 @@ pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_accept::*; +pub(crate) use direct_turn_dispatch::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_stream::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index f2058531c..4538e1c57 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -831,10 +831,10 @@ fn direct_thread_visible_item( direct_thread_event_item(root, item) } -/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。 +/// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 /// /// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的 -/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上 +/// 开口用户条目。发点在 `turn/start` 之后时,"放行到 `turn/start` 之间"的失败(连不上 /// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮** /// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成 /// 一个 0.0 秒的假回合。 @@ -846,7 +846,7 @@ pub(crate) fn emit_direct_thread_user_item( item: &serde_json::Value, ) -> Option { let entry_item = direct_thread_event_item(root, item)?; - // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令接单化」后续更新), + // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); append_direct_thread_event( @@ -3300,11 +3300,11 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由 GUI 命令在**接单之后、起 codex 之前**落盘("落盘即接单"),这里不再重复写; + // 用户条目由放行半在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: - // 不落盘,因为落盘的时机属于接单动作,不属于这里。 + // 不落盘,因为落盘的时机属于放行动作,不属于这里。 let mut direct_turn_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); @@ -3569,8 +3569,8 @@ impl CodexAppServerConnection { let direct_turn_user_item_id = direct_turn_user_item .as_ref() .and_then(direct_thread_item_identity); - // 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有 - // (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、 + // 逻辑回合的**边界**不在这里:开始事件由认领动作发出、兜底由放行占用对象持有 + // (`direct_turn_dispatch.rs`)。本轮的用户条目也不在这里下发——发点在放行之后、 // 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。 // // 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的 @@ -4528,11 +4528,14 @@ pub(crate) fn cancel_direct_codex_turn_at( // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 + // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的放行。 complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); + // 这一轮不会再有人替它收尾(守卫刚被兜底释放),占用也在这里解除了:踢一脚让队列继续。 + // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 + crate::agent::kick_direct_queue_dispatch(root); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -8048,12 +8051,8 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = + crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): @@ -8213,14 +8212,10 @@ done let _active_invocation = crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") .expect("enter direct invocation"); - // 生产入口(`chat_with_game_creator_direct_codex`)在起 codex 之前先接单,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行,再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 - let _reservation = crate::agent::direct_turn_accept::DirectTurnReservation::accept( - &thread_id, - "turn-0001", - Some("direct-codex:turn-0001:user"), - ) - .expect("accept logical turn"); + let _reservation = + crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) .expect("persist opener user item"); // 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`): diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 5e00818dd..971d41931 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -437,14 +437,12 @@ mod tests { async fn a_replaced_active_turn_marks_the_batch_stale() { let (_temp, root) = project(); std::fs::write(root.join("code.js"), "unchanged").unwrap(); - // 身份来自逻辑回合(Thread Manager):接单才是"这一轮在跑"的唯一登记。 + // 身份来自逻辑回合(Thread Manager):放行才是"这一轮在跑"的唯一登记。 let owner = Arc::new(std::sync::Mutex::new(Some( - DirectTurnReservation::accept( + DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "turn-before", - None, - ) - .unwrap(), + ), ))); let swap = Arc::clone(&owner); let result = read_batch_with( @@ -455,14 +453,10 @@ mod tests { let result = read_file(r, f, b); let mut guard = swap.lock().unwrap(); drop(guard.take()); - *guard = Some( - DirectTurnReservation::accept( - &direct_thread_id_for_project(r), - "turn-after", - None, - ) - .unwrap(), - ); + *guard = Some(DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(r), + "turn-after", + )); result }, ) @@ -598,12 +592,10 @@ mod tests { let (_temp, root) = project(); // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); - let _turn = DirectTurnReservation::accept( + let _turn = DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "prefetch-turn", - None, - ) - .unwrap(); + ); let data = prefetch_turn_input(&root, "prefetch-turn") .await .unwrap() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index d43d954ca..fdf0ded68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -8,7 +8,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; -pub(crate) use user_input::chat_with_game_creator_direct_codex; +pub(crate) use user_input::enqueue_direct_codex_turn; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; @@ -2142,13 +2142,13 @@ pub(crate) fn record_direct_codex_failure( recovery_hint, diagnostics_suffix, ); - // 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。 + // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text); public_text } -/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用), +/// 命令边界的错误文本:可留痕的调用级入队失败在这里补一份运行错误诊断(文案里不带诊断引用), /// 其余只输出 [`DirectTurnError`] 的 `Display`。 /// /// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 @@ -2159,23 +2159,23 @@ pub(crate) fn direct_turn_error_boundary_text( client_turn_id: Option<&str>, failure: DirectTurnError, ) -> String { - direct_turn_rejection(root, client_turn_id, failure).message + direct_turn_enqueue_failure(root, client_turn_id, failure).message } -/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// /// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 -pub(crate) fn direct_turn_rejection( +pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, failure: DirectTurnError, -) -> DirectTurnRejection { +) -> DirectTurnEnqueueFailure { if !failure.is_reportable() { - return DirectTurnRejection::new(failure); + return DirectTurnEnqueueFailure::new(failure); } let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnRejection { + DirectTurnEnqueueFailure { error: failure, message, } @@ -4409,9 +4409,9 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( .collect()) } -/// 接单前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 +/// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在接单前调用(不成立就是**拒单**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队半调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, @@ -4450,8 +4450,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( prompt: &str, creation_type: Option<&str>, ) -> Result { - // CLI 入口没有"接单"这一步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在同一步骤之后才接单,两边共用这一份判据。 + // CLI 入口没有"入队 / 放行"这两步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; + // GUI 命令在入队半里过同一份判据。 check_direct_turn_preconditions(root, prompt, creation_type)?; run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, @@ -4465,7 +4465,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( .await } -async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( +pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, creation_type: Option<&str>, @@ -4495,7 +4495,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( { Ok(reply) => Ok(reply), // 走到这里的一切失败都是**回合失败**:判据已经从"错误种类"改成"发生位置"——前置条件 - // 在接单前就查过,能到这条通道的只有接单之后的事(连接、配置、历史注入、`turn/start` + // 在入队前就查过,能到这条通道的只有放行之后的事(连接、配置、历史注入、`turn/start` // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { let stage = failure.turn_failure_stage(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 39665ac14..de0bdc053 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -1,7 +1,7 @@ //! DirectProject 用户输入命令适配器。 //! -//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item -//! 深模块,回合编排仍由父模块负责。 +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item 深模块,队列交给 +//! Thread Manager;放行在 `direct_turn_dispatch`,不在这里。 use super::*; @@ -28,31 +28,33 @@ pub(crate) fn normalize_direct_client_turn_id( Ok(client_turn_id.to_string()) } -/// DirectProject 聊天命令:**只接单**,不再 await 整轮。 +/// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 边界文案仍只在这里生成一次(`Display`);但 `Err` 的含义收窄成**拒单**——接单成立之后的 -/// 一切失败(连不上 app-server、配置 / 凭据未就绪、历史注入失败、`turn/start` 被拒、模型与 -/// 交付失败)都由这一轮的占用对象收口成 `turn.completed` 带失败载荷,不再回到这条返回值上。 +/// 入队半就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt +/// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 +/// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// -/// 于是"这一轮跑成什么"只有订阅事件一个来源:命令返回 `Ok` 只说明**接单成立**。可留痕的调用级 -/// 拒绝(宿主 / 环境事实)仍在边界补一份运行错误诊断,返回串不带诊断引用。 +/// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 +/// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `direct_turn_dispatch`), +/// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 +/// 错误诊断,返回串不带诊断引用。 /// /// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;它复用同一份接单前检查与同一个命令主体,只是自己等整轮的返回值。 -/// 两个入口共用 [`direct_turn_error_boundary_text`] / [`direct_turn_rejection`],不要再各写一套判据。 +/// 打到终端上,没有事件订阅可用;两个入口共用 [`direct_turn_error_boundary_text`] / +/// [`direct_turn_enqueue_failure`],不要再各写一套判据。 /// -/// 设计见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 +/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( +pub(crate) async fn enqueue_direct_codex_turn( project_path: String, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result<(), DirectTurnRejection> { +) -> Result<(), DirectTurnEnqueueFailure> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( root, user_item, creation_type, @@ -60,17 +62,16 @@ pub(crate) async fn chat_with_game_creator_direct_codex( analytics_attempt_id, ) .await - .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) + .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: -/// `clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 前置条件 → 工程准备 -/// → 接单 → 落盘用户条目 → 后台起整轮。 +/// 入队半:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 /// -/// 这个顺序不是风格问题:接单(`DirectTurnReservation::accept`)必须在所有"接单前就能判定"的 -/// 检查之后,也必须早于用户条目落盘与 `turn/start`,否则并发拒单会晚于副作用、逻辑回合的开始 -/// 事件会排在用户消息之后。 -async fn chat_with_game_creator_direct_codex_typed( +/// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; +/// 没通过检查的东西不进队列。入队本身只做判重与容量,不再有第二种副作用——落盘、回合事件与整轮 +/// 都在放行那一侧。 +async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, creation_type: Option, @@ -78,10 +79,6 @@ async fn chat_with_game_creator_direct_codex_typed( analytics_attempt_id: Option, ) -> Result<(), DirectTurnError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - // 占用调用身份:并发拒单要早于工程准备,避免两个请求同时改同一个项目。它只挡并发,**不是** - // 首页"运行中的项目"的来源(那张表由 Thread Manager 的逻辑回合导出),但仍必须与整轮同生 - // 共死——随任务一起搬进后台。 - let active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { DirectTurnError::HostStateUnavailable { detail: redact_agent_runtime_error( @@ -96,10 +93,6 @@ async fn chat_with_game_creator_direct_codex_typed( let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; - let canonical_user_item = - serde_json::to_value(&user_item).map_err(|error| DirectTurnError::InputRejected { - detail: error.to_string(), - })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -107,96 +100,31 @@ async fn chat_with_game_creator_direct_codex_typed( let detail = redact_agent_runtime_error(root, &error, 1800); DirectTurnError::EnvironmentNotReady { detail } })?; - // 接单:从这里开始这一轮就成立了。开始事件的身份由 `clientTurnId` 推导,**不读盘回填** - // ——开始事件发生在用户条目落盘之前,而落盘本身也可能失败。 - let thread_id = direct_thread_id_for_project(root); - let user_item_id = direct_codex_user_item_id_for_client_turn_id(&turn_id); - let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; - // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 - if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { - // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 - // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 - // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 - // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 - // 用户消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); - return Ok(()); - } - // 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上 - // app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到 - // `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。 - crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item); - let capture = crate::analytics::gui::capture_writer_context(); - let root = root.to_path_buf(); - tauri::async_runtime::spawn(async move { - run_accepted_direct_turn( - root, - turn_id, - user_prompt, - creation_type, - canonical_user_item, - capture, - analytics_attempt_id, - active_invocation, - reservation, - ) - .await; - }); - Ok(()) -} - -/// 接单之后的整轮:命令不再 await 它,它的收场只走事件流。 -/// -/// 三条收场路径都在这里收口:正常(深层的终态出口写 `turn.completed`)、失败(没有深层终态的 -/// 早退由这里的占用对象补)、任务被丢弃 / panic(占用对象的 `Drop` 补 `host-dropped`)。 -/// -/// 两个守卫都**必须活到整轮结束**,所以随任务搬进来,不留在命令里: -/// `_active_invocation` 是这一轮的调用身份(并发拒单与首页在途回合都读它),`reservation` -/// 是逻辑回合的占用。 -#[allow(clippy::too_many_arguments)] -async fn run_accepted_direct_turn( - root: std::path::PathBuf, - turn_id: String, - user_prompt: String, - creation_type: Option, - canonical_user_item: serde_json::Value, - capture: Option<( - crate::analytics::contract::Context, - crate::analytics::store::AnalyticsWriter, - )>, - analytics_attempt_id: Option, - _active_invocation: DirectTaonierActiveInvocationGuard, - reservation: DirectTurnReservation, -) { - let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); - let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( - &root, - &user_prompt, - creation_type.as_deref(), - Some(&emitter), - Some(canonical_user_item), - capture, - analytics_attempt_id.as_deref(), + // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。prompt 与 canonical 形状在这里 + // 冻结——放行不重算,所以放行没有失败出口。 + let pending = PendingDirectTurn::prepare( + turn_id, + user_item, + user_prompt, + creation_type, + analytics_attempt_id, + direct_tool_call_now_ms(), ) - .await; - match outcome { - Ok(reply) => { - // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); - } - Err(error) => { - // 接单之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 - // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + .map_err(|error| DirectTurnError::InputRejected { + detail: error.to_string(), + })?; + let thread_id = direct_thread_id_for_project(root); + match enqueue_direct_pending_turn(&thread_id, pending) { + Ok(_) => {} + Err(EnqueueRejection::QueueFull) => { + return Err(DirectTurnError::QueueFull { + limit: MAX_PENDING_DIRECT_TURNS, + }) } } + // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 + kick_direct_queue_dispatch(root); + Ok(()) } #[cfg(test)] @@ -204,46 +132,64 @@ mod tests { use super::*; use crate::agent::{consume_direct_thread, subscribe_direct_thread, DirectThreadEvent}; - /// 接单之后的早退也必须有终态。 + /// 放行之后整轮是异步的:测试只能等事件。返回从订阅之后看到的全部事件。 + async fn wait_for_completion(subscription_id: &str) -> Vec { + let mut events = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + events.extend( + consume_direct_thread(subscription_id) + .expect("consume logical turn") + .events, + ); + if events + .iter() + .any(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "回合没有收口:{events:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + } + + fn user_item(text: &str) -> DirectCodexUserItem { + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-1:user", + "content": [{ "type": "input_text", "text": text }], + })) + .expect("canonical user item") + } + + /// 放行之后发现的失败也必须收口。 /// - /// 这里用一个"目录存在但不是项目"的根制造一条**接单之后**才发现的失败(连 `run_turn` 的 - /// 收尾都走不到)。命令此时早已返回 `Ok`,前端唯一的收口依据就是事件流,所以占用对象必须 - /// 补出 `turn.completed`——这正是接单化要买的那条不变式。 + /// 命令此刻早就返回了 `Ok`(它只回报入队成立),前端唯一的收口依据就是事件流:占用对象必须补出 + /// `turn.completed`。这里用一个"目录存在但不是项目"的根制造一条放行之后才发现的失败。 #[tokio::test] - async fn a_failure_after_accept_still_closes_the_logical_turn() { + async fn a_failure_after_dispatch_still_closes_the_logical_turn() { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("not-a-project"); std::fs::create_dir_all(&root).expect("create project dir"); let thread_id = direct_thread_id_for_project(&root); let subscription = subscribe_direct_thread(&thread_id); let _ = consume_direct_thread(&subscription.subscription_id); - let reservation = - DirectTurnReservation::accept(&thread_id, "turn-1", Some("direct-codex:turn-1:user")) - .expect("accept logical turn"); - let invocation = - DirectTaonierActiveInvocationGuard::enter(&root, "turn-1").expect("enter invocation"); - run_accepted_direct_turn( - root.clone(), - "turn-1".to_string(), - "你好".to_string(), + enqueue_direct_codex_turn_typed( + &root, + user_item("你好"), None, - serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "你好" }], - }), + Some("turn-1".to_string()), None, - None, - invocation, - reservation, ) - .await; + .await + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminal = events .iter() .filter_map(|event| match event { @@ -262,20 +208,22 @@ mod tests { let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( !failure.message.trim().is_empty(), - "接单之后的失败必须带上原因" + "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); + // 占用已释放:队列继续走得动。 + assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); } /// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。 /// - /// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败 - /// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置 - /// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户 - /// 气泡再自成一个 0.0 秒的假回合。 + /// 现场(用户可见的坏体验):放行、用户条目落盘之后,整轮在 `turn/start` 之前就失败(连不上 + /// app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置落进**上一轮** + /// 的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户气泡再自成一个 + /// 0.0 秒的假回合。 /// - /// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目" - /// 一定在最前面,之后才可能有失败终态。 + /// 判据取事件流的前两条:放行任务的顺序是"开始 → 下发用户条目 → 跑整轮",所以终态一定在它们 + /// 之后。 #[tokio::test] async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() { let temp = tempfile::tempdir().expect("temp dir"); @@ -285,48 +233,50 @@ mod tests { let thread_id = direct_thread_id_for_project(&root); let subscription = subscribe_direct_thread(&thread_id); let _ = consume_direct_thread(&subscription.subscription_id); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "hello" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("hello"), None, Some("turn-1".to_string()), None, ) .await - .expect("接单成立:命令只回报接单"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; + // 队列事件先于放行:`queue.enqueued` → 放行(开始 + 离开队列)→ 开口用户条目 → 终态。 + let started = events + .iter() + .position(|event| matches!(event, DirectThreadEvent::TurnStarted { .. })) + .expect("这一轮必须被放行"); assert!( matches!( - events.first(), + events.get(started), Some(DirectThreadEvent::TurnStarted { user_item_id, .. }) if user_item_id.as_deref() == Some("direct-codex:turn-1:user") ), - "第一条必须是带身份的回合开始:{events:?}" + "放行的第一条事件必须是带身份的回合开始:{events:?}" ); + // 开口用户条目必须是这一轮里的**第一条条目事件**:它之前只许有回合边界与队列事件。 + let opening = events + .iter() + .position(|event| event.item_id().is_some()) + .expect("本轮的开口用户条目必须下发"); assert!( matches!( - events.get(1), + events.get(opening), Some(DirectThreadEvent::ItemCompleted { item, .. }) if item.item_id() == "direct-codex:turn-1:user" ), - "第二条必须是本轮的开口用户条目:{events:?}" + "第一条条目事件必须是本轮的开口用户条目:{events:?}" ); + assert!(opening > started, "用户条目只能在放行之后:{events:?}"); let terminal = events .iter() .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })); assert!( - terminal.is_none_or(|index| index > 1), + terminal.is_none_or(|index| index > opening), "终态只能在用户条目之后:{events:?}" ); // 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。 @@ -338,14 +288,15 @@ mod tests { ); } - /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// 放行之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 /// /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 - /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 - /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 - /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + /// 说明、横幅又一份),而且 `Err` 的含义是**入队失败**——前端会把它读成"这一轮没开始"。历史 + /// 追加写有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把 + /// 这条路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能继续)。 #[tokio::test] - async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + async fn a_history_write_failure_after_dispatch_closes_the_turn_instead_of_failing_the_command() + { let temp = tempfile::tempdir().expect("temp dir"); let root = temp.path().join("direct-history-write-failure"); crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") @@ -359,27 +310,18 @@ mod tests { "9", ) .expect("write history contention injection"); - let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-1:user", - "content": [{ "type": "input_text", "text": "生成一个游戏" }], - })) - .expect("canonical user item"); - chat_with_game_creator_direct_codex_typed( + enqueue_direct_codex_turn_typed( &root, - user_item, + user_item("生成一个游戏"), None, Some("turn-1".to_string()), None, ) .await - .expect("接单之后的失败不再回到命令返回值:命令只回报接单成立"); + .expect("入队成立:命令只回报入队"); - let events = consume_direct_thread(&subscription.subscription_id) - .expect("consume logical turn") - .events; + let events = wait_for_completion(&subscription.subscription_id).await; let terminals = events .iter() .filter_map(|event| match event { @@ -398,20 +340,14 @@ mod tests { "{}", failure.message ); - // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 - // 不许出现在这里(它只属于可留痕的拒单)。 + // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的入队失败)。 assert!( !failure.message.contains("direct-codex-failure"), "{}", failure.message ); - // 占用已释放:下一轮还能接单。 + // 占用已释放:下一轮还能继续。 assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); - assert!(DirectTurnReservation::accept( - &thread_id, - "turn-2", - Some("direct-codex:turn-2:user") - ) - .is_ok()); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index a83e68baa..f3b844034 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -43,14 +43,14 @@ struct SubscriberState { cursor: u64, } -/// 一条正在跑的逻辑回合的占用:接单时登记,终态写出时解除。 +/// 一条正在跑的逻辑回合的占用:放行时登记,终态写出时解除。 /// /// 它同时是首页「运行中的项目」快照的**唯一事实源**([`list_direct_active_turns`]):这一格的 /// 生命周期就是"这一轮在不在跑",进度字段由运行时那一侧经 [`update_direct_thread_active_turn`] /// 回填。任务侧不再另建一张活动回合表——同一件事只许有一处真相。 /// /// 两个身份别混: -/// - `token` 是这一次接单的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, +/// - `token` 是这一次放行的占用身份:终态出口只有拿着同一个 token 的占用对象才能写兜底终态, /// 避免迟到的旧占用把新回合的边界顶掉。它不对外。 /// - `turn_id` 是给界面看的回合身份(`clientTurnId` 派生),只服务快照与进度回填的匹配。 #[derive(Clone, Debug)] @@ -227,46 +227,6 @@ impl DirectThreadManager { } } - /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 - /// - /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 - /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 - /// "撞的是同一轮还是另一轮"。 - fn accept_turn( - &mut self, - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, - ) -> Result { - { - let thread = self.threads.entry(thread_id.to_string()).or_default(); - if let Some(active) = thread.active_turn.as_ref() { - return Err(active.turn_id.clone()); - } - thread.active_turn = Some(ActiveDirectTurn { - token: token.to_string(), - turn_id: turn_id.to_string(), - project_name: std::path::Path::new(thread_id) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_string), - started_at: started_at_ms, - // 与"还没有任何进度事件"的状态一致:运行时给出的第一条进度会覆盖它。 - status: "accepted".to_string(), - activity: Some("request-accepted".to_string()), - updated_at: started_at_ms, - sequence: 0, - }); - } - Ok(self.append( - thread_id, - DirectThreadEvent::turn_started(started_at_ms).with_user_item_id(user_item_id), - )) - } - /// 运行时回填这一轮的进度。只认"仍在跑 + 回合身份一致 + 序号不倒退"的那一次。 /// /// 返回是否真的写进去了:没有未收口的回合、身份对不上(上一轮迟到的进度)、序号倒退 @@ -449,10 +409,9 @@ impl DirectThreadManager { /// 放行:同一个临界区里取队首 → 登记占用 → 写 `turn.started` 与 `queue.removed{dispatched}`。 /// - /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的。这两件事 - /// 都由这里判,调用方(kick)不需要先探一遍再决定——探两次就是两个中间态。 - /// - /// 认领认的是队首,不是调用方点名的某一条:待发消息的顺序就是事件顺序,没有第三种来源。 + /// 返回 `None` 表示现在**不该放行**:这个 thread 已经有未收口的回合,或者队列是空的——两件事 + /// 都由这里判,调用方(kick)不必先探一遍再决定。认领认的永远是队首:待发消息的顺序就是事件 + /// 顺序,没有第二种来源。 fn claim_pending_turn( &mut self, thread_id: &str, @@ -773,27 +732,6 @@ pub(crate) fn append_direct_thread_event( event } -/// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 -/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: -/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 -/// 不要动当前回合。 -pub(crate) fn accept_direct_thread_turn( - thread_id: &str, - token: &str, - turn_id: &str, - user_item_id: Option<&str>, - started_at_ms: u64, -) -> Result<(), String> { - { - global_direct_thread_manager() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .accept_turn(thread_id, token, turn_id, user_item_id, started_at_ms)?; - } - notify_direct_thread_subscribers(thread_id); - Ok(()) -} - /// 入队一条待发消息(队列的成员与顺序就是事件列表,见 `direct_thread_queue`)。 pub(crate) fn enqueue_direct_pending_turn( thread_id: &str, @@ -830,7 +768,7 @@ pub(crate) fn remove_direct_pending_turn( /// 认领队首并登记占用(放行的原子步骤,见 [`DirectThreadManager::claim_pending_turn`])。 /// -/// `None` = 现在不该放行(已有未收口的回合,或队列为空)。 +/// `None` = 现在不该放行:已有未收口的回合,或队列为空。 pub(crate) fn claim_direct_pending_turn(thread_id: &str) -> Option { let claimed = { global_direct_thread_manager() @@ -1281,7 +1219,7 @@ mod tests { ); } - /// 首页快照就是逻辑回合的导出:接单即出现、进度按序号回填、收口即消失。 + /// 首页快照就是逻辑回合的导出:放行即出现、进度按序号回填、收口即消失。 fn snapshot_of( manager: &DirectThreadManager, thread_id: &str, @@ -1299,8 +1237,11 @@ mod tests { assert!(snapshot_of(&manager, thread_id).is_none()); manager - .accept_turn(thread_id, "token-1", "turn-1", Some("u-1"), FIXED_AT_MS) - .expect("accept"); + .enqueue_pending_turn(thread_id, pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn(thread_id, FIXED_AT_MS) + .expect("claim head"); let accepted = snapshot_of(&manager, thread_id).expect("accepted turn is visible"); assert_eq!(accepted.turn_id, "turn-1"); assert_eq!(accepted.project_name.as_deref(), Some("快照项目")); @@ -1340,22 +1281,6 @@ mod tests { assert!(snapshot_of(&manager, thread_id).is_none()); } - /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 - /// 里,界面拿它匹配不了自己发出的那一轮。 - #[test] - fn accept_conflict_returns_the_existing_turn_id() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager - .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) - .expect("accept"); - - let conflict = manager - .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) - .err(); - - assert_eq!(conflict.as_deref(), Some("turn-1")); - } - fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { PendingDirectTurn::prepare( client_turn_id.to_string(), @@ -1368,6 +1293,7 @@ mod tests { .expect("canonical user item"), format!("消息 {client_turn_id}"), None, + None, FIXED_AT_MS, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs index 2785c87cb..18425b202 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -35,6 +35,8 @@ pub(crate) struct PendingDirectTurn { /// 入队检查产出的 prompt:放行不重算。 pub(crate) prompt: String, pub(crate) creation_type: Option, + /// 这一轮的运行埋点句柄:由前端在入队时生成,放行开跑时用同一份身份。 + pub(crate) analytics_attempt_id: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, } @@ -49,6 +51,7 @@ impl PendingDirectTurn { user_item: DirectCodexUserItem, prompt: String, creation_type: Option, + analytics_attempt_id: Option, at: u64, ) -> Result { let canonical_user_item = serde_json::to_value(&user_item)?; @@ -58,6 +61,7 @@ impl PendingDirectTurn { canonical_user_item, prompt, creation_type, + analytics_attempt_id, at, }) } @@ -124,6 +128,7 @@ mod tests { user_item("生成一个游戏", "direct-codex:turn-1:user"), "生成一个游戏".to_string(), creation_type.map(str::to_string), + None, 1_700_000_000_123, ) .expect("prepare pending turn") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index a4649938e..9140d232c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -215,7 +215,7 @@ impl DirectThreadRequestKind { /// 一条待发消息离开队列的原因。 /// -/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 /// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -223,7 +223,7 @@ impl DirectThreadRequestKind { pub(crate) enum DirectQueueRemovalReason { /// 用户取消了这条待发消息。 Cancelled, - /// 放行:这条待发消息已经接单并成为回合。 + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 Dispatched, } @@ -293,7 +293,7 @@ impl DirectTurnFailure { pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] TurnStarted { - /// 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 /// `turn/start` 的时刻)。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional, as = "Option")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs deleted file mode 100644 index fce538000..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! DirectProject 的接单:把"一条用户消息被接单"变成 Thread Manager 里一对必然成对的逻辑回合事件。 -//! -//! 这个模块只有一件事,别再往里加第二件:**接单成立的那一刻**在同一个临界区里拒绝并发、登记占用、 -//! 发出逻辑回合开始事件;占用对象持有这一轮的终态出口——正常 / 失败 / 接单后的前置失败谁先写谁算, -//! 都没写时由 `Drop` 补一条 `host-dropped`。 -//! -//! 为什么回合边界不能继续镜像 Codex 原生回合:`turn/start` 之前的失败(连不上 app-server、配置未 -//! 就绪失败、历史注入失败)根本没有原生回合可以镜像,而它们同样是"这一轮已经成立"。设计见 -//! `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - -use uuid::Uuid; - -use super::{ - accept_direct_thread_turn, complete_direct_thread_turn_if_reserved, direct_tool_call_now_ms, - DirectTurnError, DirectTurnTerminal, -}; - -/// 一次接单的占用。持有它就代表这一轮还没收口。 -/// -/// 生命周期由调用方决定:命令把整轮任务 spawn 出去时把它一起搬进任务,任务结束(正常或失败) -/// 时它随任务一起 drop。**持有顺序要与单飞锁一致**:单飞锁先声明、占用后声明,drop 时占用先收尾, -/// 新回合不可能插到中间。 -pub(crate) struct DirectTurnReservation { - thread_id: String, - token: String, - user_item_id: Option, -} - -impl DirectTurnReservation { - /// 接单:登记占用并发出逻辑回合开始事件。 - /// - /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 - /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` - /// 是两件事:前者来自调用方,后者只活在这个进程里。 - /// - /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` - /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 - /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 - pub(crate) fn accept( - thread_id: &str, - client_turn_id: &str, - user_item_id: Option<&str>, - ) -> Result { - let token = Uuid::new_v4().to_string(); - accept_direct_thread_turn( - thread_id, - &token, - client_turn_id, - user_item_id, - direct_tool_call_now_ms(), - ) - .map_err(|existing| DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing, - incoming_invocation_id: client_turn_id.to_string(), - })?; - Ok(Self { - thread_id: thread_id.to_string(), - token, - user_item_id: user_item_id.map(str::to_string), - }) - } - - pub(crate) fn thread_id(&self) -> &str { - &self.thread_id - } - - /// 接单之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 - /// - /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { - complete_direct_thread_turn_if_reserved( - &self.thread_id, - &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), - ) - } -} - -impl Drop for DirectTurnReservation { - fn drop(&mut self) { - // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{ - consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, - }; - - /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 - fn watch(thread_id: &str) -> String { - let bootstrap = subscribe_direct_thread(thread_id); - let _ = consume_direct_thread(&bootstrap.subscription_id); - bootstrap.subscription_id - } - - fn pending(subscription_id: &str) -> Vec { - consume_direct_thread(subscription_id) - .expect("consume") - .events - } - - fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { - events - .iter() - .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) - .collect() - } - - fn unique_thread(label: &str) -> String { - format!("accept-test-{label}-{}", Uuid::new_v4()) - } - - #[test] - fn accept_emits_a_logical_turn_started_and_holds_the_turn() { - let thread = unique_thread("started"); - let subscription = watch(&thread); - - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let events = pending(&subscription); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - DirectThreadEvent::TurnStarted { user_item_id, .. } => { - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected turn.started, got {other:?}"), - } - assert!(direct_thread_turn_is_active(&thread)); - drop(reservation); - } - - #[test] - fn a_second_accept_is_rejected_while_the_turn_is_open() { - let thread = unique_thread("busy"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - // 先取走第一条接单自己的开始事件,之后的"空"才只说明被拒的这一次没写东西。 - assert_eq!(pending(&subscription).len(), 1); - - let rejected = DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")); - - assert!(matches!( - rejected, - Err(DirectTurnError::TurnAlreadyRunning { .. }) - )); - let events = pending(&subscription); - assert_eq!(events.len(), 0, "被拒的接单不许产生事件:{events:?}"); - drop(reservation); - } - - /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 - /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, - /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 - #[test] - fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { - let thread = unique_thread("same-turn-conflict"); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - let DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } = &same_turn - else { - panic!("expected a concurrency rejection, got {same_turn:?}"); - }; - assert_eq!(existing_invocation_id, "turn-1"); - assert_eq!(incoming_invocation_id, "turn-1"); - assert!( - same_turn.to_string().contains("同一轮消息仍在处理中"), - "{same_turn}" - ); - - let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { - Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), - Err(error) => error, - }; - assert!(matches!( - &other_turn, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" - )); - assert!( - other_turn.to_string().contains("另一条 Direct 客户端回合"), - "{other_turn}" - ); - drop(reservation); - } - - #[test] - fn drop_without_a_terminal_writes_a_host_dropped_terminal() { - let thread = unique_thread("drop"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - assert!(reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped())); - assert!(!direct_thread_turn_is_active(&thread)); - - // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { - user_item_id, - failure: Some(failure), - .. - } => { - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert_eq!(user_item_id.as_deref(), Some("u-1")); - } - other => panic!("expected a failed terminal, got {other:?}"), - } - } - - #[test] - fn the_deep_terminal_wins_and_the_fallback_stays_silent() { - let thread = unique_thread("deep"); - let subscription = watch(&thread); - let reservation = - DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - - // 深层收口:真正跑完这一轮的代码算出来的终态。 - let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), - 2_000, - ) - .with_user_item_id(Some("u-1")); - crate::agent::complete_direct_thread_turn(&thread, deep); - - assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), - "深层已收口时兜底不许再写" - ); - drop(reservation); - - let events = pending(&subscription); - let completed = turn_completed_events(&events); - assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); - match completed[0] { - DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) - ); - } - other => panic!("expected a terminal, got {other:?}"), - } - } - - #[test] - fn the_thread_can_be_accepted_again_after_the_turn_is_settled() { - let thread = unique_thread("again"); - let first = DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); - drop(first); - - let second = - DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")).expect("second accept"); - - assert!(direct_thread_turn_is_active(&thread)); - drop(second); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs new file mode 100644 index 000000000..689298c53 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs @@ -0,0 +1,357 @@ +//! DirectProject 的放行:把队首那条待发消息变成一轮真的回合。 +//! +//! 这个模块两件事,别再往里加第三件: +//! 1. [`DirectTurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; +//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(取调用身份 → 落盘用户条目 → 下发 → 跑回合)。 +//! +//! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 +//! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 +//! `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +use std::path::{Path, PathBuf}; + +use super::{ + append_direct_project_user_message_at, claim_direct_pending_turn, + complete_direct_thread_turn_if_reserved, direct_thread_id_for_project, direct_tool_call_now_ms, + redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, + DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTaonierActiveInvocationGuard, + DirectTurnError, DirectTurnTerminal, PendingDirectTurn, +}; + +/// 一次放行的占用。持有它就代表这一轮还没收口。 +/// +/// 生命周期由调用方决定:登记与开始事件已经由 [`kick_direct_queue_dispatch`] 在同一临界区里写好, +/// 这里只接住这一轮的终态出口,并随放行任务一起 drop(正常 / 失败 / panic 都走同一个出口)。 +pub(crate) struct DirectTurnReservation { + thread_id: String, + token: String, + user_item_id: Option, +} + +impl DirectTurnReservation { + /// 接管一次放行的占用:占用登记与 `turn.started` 已由 Thread Manager 的认领写好。 + pub(crate) fn resume(thread_id: &str, dispatched: &DirectDispatchedTurn) -> Self { + Self { + thread_id: thread_id.to_string(), + token: dispatched.token.clone(), + user_item_id: dispatched.pending.user_item_id(), + } + } + + /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 + /// + /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 + pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + complete_direct_thread_turn_if_reserved( + &self.thread_id, + &self.token, + terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + ) + } + + /// 测试用:直接开一轮占用,等价于"入队 + 认领",不经过 Tauri 命令与真实的用户条目。 + #[cfg(test)] + pub(crate) fn accept_for_test(thread_id: &str, client_turn_id: &str) -> Self { + let pending = PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "测试消息"}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + "测试消息".to_string(), + None, + None, + direct_tool_call_now_ms(), + ) + .expect("prepare pending turn"); + super::enqueue_direct_pending_turn(thread_id, pending).expect("enqueue test turn"); + let dispatched = claim_direct_pending_turn(thread_id).expect("claim test turn"); + Self::resume(thread_id, &dispatched) + } +} + +impl Drop for DirectTurnReservation { + fn drop(&mut self) { + // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 + let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + // 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。 + // panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。 + kick_direct_queue_dispatch(Path::new(&self.thread_id)); + } +} + +/// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。 +/// +/// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 +/// **占用释放**(正常 / 失败 / panic 共用,见 [`DirectTurnReservation`] 的 `Drop`)、**中止路径** +/// (守卫被兜底释放时那一轮不会再有人替它收尾)。 +/// +/// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; +/// 这两种情况都会在下一次释放时再踢。 +pub(crate) fn kick_direct_queue_dispatch(root: &Path) { + let thread_id = direct_thread_id_for_project(root); + let Some(dispatched) = claim_direct_pending_turn(&thread_id) else { + return; + }; + let reservation = DirectTurnReservation::resume(&thread_id, &dispatched); + let root = root.to_path_buf(); + tauri::async_runtime::spawn(async move { + run_dispatched_direct_turn(root, dispatched, reservation).await; + }); +} + +/// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。 +/// +/// 这一段的每个失败都是**回合失败**:占用与开始事件已经写好,只走占用对象的终态出口,不再回到任何 +/// 命令的返回值上。 +async fn run_dispatched_direct_turn( + root: PathBuf, + dispatched: DirectDispatchedTurn, + reservation: DirectTurnReservation, +) { + let turn_id = dispatched.pending.client_turn_id.clone(); + // 调用身份整轮持有:付费美术、执行会话、MCP、校验、上下文预取都读它。这里拿不到身份说明这个 + // 项目上还有另一条调用没放(残留守卫 / 别处的入口),按回合失败说清原因;队列照常继续。 + let active_invocation = match DirectTaonierActiveInvocationGuard::enter(&root, &turn_id) { + Ok(guard) => guard, + Err(failure) => { + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + }; + // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 + if let Err(error) = + append_direct_project_user_message_at(&root, &dispatched.pending.canonical_user_item) + { + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 + // 消息的助手回复,而且失败会被静默掉。 + let failure = DirectTurnError::EnvironmentNotReady { + detail: redact_agent_runtime_error( + &root, + &format!("写入本项目对话历史失败:{error}"), + 600, + ), + }; + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + return; + } + // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 + // 未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮。 + crate::agent::codex_app_server::emit_direct_thread_user_item( + &root, + &dispatched.pending.canonical_user_item, + ); + let capture = crate::analytics::gui::capture_writer_context(); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); + let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, + &dispatched.pending.prompt, + dispatched.pending.creation_type.as_deref(), + Some(&emitter), + Some(dispatched.pending.canonical_user_item.clone()), + capture, + dispatched.pending.analytics_attempt_id.as_deref(), + ) + .await; + match outcome { + Ok(reply) => { + // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 + emitter.emit("completed", Some("none"), Some(reply), None); + } + Err(error) => { + // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 + // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 + reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + } + } + // 调用身份先放,再让占用对象的 `Drop` 收尾并踢下一脚:顺序反过来就会出现"守卫还握着、队首却已经 + // 可以放行"的窗口——那一脚会被自己的守卫挡成一条假的回合失败。 + drop(active_invocation); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + consume_direct_thread, direct_thread_turn_is_active, enqueue_direct_pending_turn, + subscribe_direct_thread, DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, + PendingDirectTurn, + }; + use uuid::Uuid; + + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 + fn watch(thread_id: &str) -> String { + let bootstrap = subscribe_direct_thread(thread_id); + let _ = consume_direct_thread(&bootstrap.subscription_id); + bootstrap.subscription_id + } + + fn pending(subscription_id: &str) -> Vec { + consume_direct_thread(subscription_id) + .expect("consume") + .events + } + + fn turn_completed_events(events: &[DirectThreadEvent]) -> Vec<&DirectThreadEvent> { + events + .iter() + .filter(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + .collect() + } + + /// 一条待发消息。放行路径只读它的身份与产物,所以这里用最小可用形状。 + fn pending_turn(client_turn_id: &str) -> PendingDirectTurn { + PendingDirectTurn::prepare( + client_turn_id.to_string(), + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": format!("消息 {client_turn_id}")}], + "id": format!("direct-codex:{client_turn_id}:user"), + })) + .expect("canonical user item"), + format!("消息 {client_turn_id}"), + None, + None, + direct_tool_call_now_ms(), + ) + .expect("prepare pending turn") + } + + /// 真实存在的项目目录不是这条用例的判据,用唯一的假路径当线程身份即可:认领与占用的语义只认 + /// 这个字符串。 + fn unique_thread(label: &str) -> String { + format!("dispatch-test-{label}-{}", Uuid::new_v4()) + } + + fn enqueue(thread_id: &str, client_turn_id: &str) { + enqueue_direct_pending_turn(thread_id, pending_turn(client_turn_id)).expect("enqueue"); + } + + /// 放行的占用只认**自己的 token**:认领写下的占用身份与这一轮绑死,别人的终态盖不上它。 + #[test] + fn a_foreign_token_cannot_close_the_turn() { + let thread = unique_thread("token"); + enqueue(&thread, "turn-1"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let owner = DirectTurnReservation::resume(&thread, &dispatched); + let foreign = DirectTurnReservation::resume( + &thread, + &DirectDispatchedTurn { + token: "foreign-token".to_string(), + pending: pending_turn("turn-1"), + }, + ); + + assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(direct_thread_turn_is_active(&thread)); + assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!direct_thread_turn_is_active(&thread)); + // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 + drop(foreign); + drop(owner); + } + + /// 深层终态先写,占用对象的兜底就闭嘴:一轮只许有一条终态。 + #[test] + fn the_deep_terminal_wins_and_the_fallback_stays_silent() { + let thread = unique_thread("deep"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let reservation = DirectTurnReservation::resume(&thread, &dispatched); + + // 深层收口:真正跑完这一轮的代码算出来的终态。 + let deep = DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), + 2_000, + ) + .with_user_item_id(Some("direct-codex:turn-1:user")); + crate::agent::complete_direct_thread_turn(&thread, deep); + + assert!( + !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + "深层已收口时兜底不许再写" + ); + drop(reservation); + + let events = pending(&subscription); + let completed = turn_completed_events(&events); + assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); + match completed[0] { + DirectThreadEvent::TurnCompleted { failure, .. } => { + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); + } + other => panic!("expected a terminal, got {other:?}"), + } + } + + /// 没有终态的 Drop 会补一条 `host-dropped`,并且**踢一脚**让队里排着的下一条接上。 + #[test] + fn dropping_a_reservation_closes_it_and_lets_the_queue_move_on() { + let thread = unique_thread("drop"); + let subscription = watch(&thread); + enqueue(&thread, "turn-1"); + enqueue(&thread, "turn-2"); + let dispatched = claim_direct_pending_turn(&thread).expect("claim head"); + let reservation = DirectTurnReservation::resume(&thread, &dispatched); + + drop(reservation); + + // 第一条按宿主丢弃收口,第二条已经接上:放行不等人,也不必等下一次用户动作。 + let events = pending(&subscription); + let terminal = events + .iter() + .position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. })) + .expect("第一条必须被收口"); + assert!( + matches!( + events.get(terminal), + Some(DirectThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) + if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + ), + "{events:?}" + ); + let next_started = events.iter().position(|event| { + matches!( + event, + DirectThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-2:user") + ) + }); + assert!( + next_started.is_some_and(|index| index > terminal), + "队列里排着的下一条必须在收口之后立刻放行:{events:?}" + ); + } + + /// 已经有回合在跑、或队列为空时,踢一脚什么都不做。 + #[test] + fn kicking_does_nothing_while_a_turn_is_open_or_the_queue_is_empty() { + let empty = unique_thread("empty"); + kick_direct_queue_dispatch(Path::new(&empty)); + assert!(!direct_thread_turn_is_active(&empty)); + + let busy = unique_thread("busy"); + enqueue(&busy, "turn-1"); + enqueue(&busy, "turn-2"); + let dispatched = claim_direct_pending_turn(&busy).expect("claim head"); + let reservation = DirectTurnReservation::resume(&busy, &dispatched); + + kick_direct_queue_dispatch(Path::new(&busy)); + + // 仍在跑的那一轮没有被顶掉:占用身份还是第一条。 + assert!(direct_thread_turn_is_active(&busy)); + drop(reservation); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 21684a950..774fa34aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -5,13 +5,13 @@ //! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 //! 伪结构化,也不靠对错误文本做子串匹配。 //! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`接单化` 之后的口径): -//! - **接单前**发生的 = 拒单:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 +//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): +//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 //! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **接单后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 +//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 //! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 //! -//! 所以"同一种错误在接单前后走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 +//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 //! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 //! //! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: @@ -38,7 +38,7 @@ const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 /// -/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -62,7 +62,7 @@ impl DirectCodexFailureStage { /// 宿主等不到模型回执时,撞的是哪一条上限。 /// -/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] @@ -227,7 +227,7 @@ pub(crate) enum DirectTurnFailureKind { TransportFailed, /// app-server 或上游明确拒绝了这次请求。 RequestRejected, - /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 EnvironmentNotReady, /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 TurnInterrupted, @@ -235,7 +235,7 @@ pub(crate) enum DirectTurnFailureKind { HostDropped, } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 /// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 @@ -385,7 +385,7 @@ impl DirectModelCallKind { )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { - // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── + // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 @@ -397,6 +397,10 @@ pub(crate) enum DirectTurnError { existing_invocation_id: String, incoming_invocation_id: String, }, + /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 + /// + /// 上限只落在宿主这一处(`MAX_PENDING_DIRECT_TURNS`),随载荷带出去,界面不自己数一份。 + QueueFull { limit: usize }, /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 ProjectRootUnanchored { cause: String }, /// 项目目录不存在或不是绝对路径。 @@ -407,13 +411,13 @@ pub(crate) enum DirectTurnError { InputRejected { detail: String }, /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**接单前后都可能出现**:接单前是拒单(工程 / 凭据还没准备好), - /// 接单后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 + /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 + /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 EnvironmentNotReady { detail: String }, /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 HostStateUnavailable { detail: String }, - // ───────── 回合失败:接单之后发生,这一轮已经开始 ───────── + // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 ModelCallFailed { kind: DirectModelCallKind, @@ -442,7 +446,7 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } -/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 /// /// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 /// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 @@ -450,14 +454,14 @@ pub(crate) enum DirectTurnError { #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnRejection { +pub(crate) struct DirectTurnEnqueueFailure { /// 结构化变体:界面按 `error.type` 分流,不解析文案。 pub(crate) error: DirectTurnError, /// 可展示文案(`Display` 的唯一出口)。 pub(crate) message: String, } -impl DirectTurnRejection { +impl DirectTurnEnqueueFailure { pub(crate) fn new(error: DirectTurnError) -> Self { Self { message: error.to_string(), @@ -467,12 +471,12 @@ impl DirectTurnRejection { } impl DirectTurnError { - /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 + /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是用户自己 - /// 就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。判据按变体分, - /// 不看文案。 + /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 + /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 + /// 判据按变体分,不看文案。 /// /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 /// 就是同一件事留两份。 @@ -482,6 +486,7 @@ impl DirectTurnError { Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } + | Self::QueueFull { .. } | Self::TurnAlreadyRunning { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 @@ -644,6 +649,10 @@ impl fmt::Display for DirectTurnError { formatter, "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" ), + Self::QueueFull { limit } => write!( + formatter, + "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" + ), Self::TurnAlreadyRunning { existing_invocation_id, incoming_invocation_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 7c78d7215..5b01d1f9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -5,7 +5,7 @@ //! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; //! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 //! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_accept.rs` 的接单占用对象里: +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `direct_turn_dispatch.rs` 的放行占用对象里: //! 这个模块只负责"什么算失败、原因怎么写"。 //! //! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 68b5c610d..4d96baa21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5567,6 +5567,29 @@ pub(crate) fn list_game_creator_direct_active_turns( list_direct_active_turns() } +/// 取消一条**还没放行**的待发消息。 +/// +/// 结果分三种:真的从队列移除了(并以 `queue.removed{cancelled}` 告知所有订阅者)、这一条已经被 +/// 放行(正在跑的那一轮就是它,不能按待发消息取消)、队列里本来就没有这个身份。判据只看队列与占用, +/// 不看界面状态——同一份事实源在宿主。 +#[tauri::command] +pub(crate) async fn remove_direct_project_pending_turn( + project_path: String, + client_turn_id: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let thread_id = direct_thread_id_for_project(root); + Ok(remove_direct_pending_turn( + &thread_id, + client_turn_id.trim(), + )) + }) + .await + .map_err(|error| format!("取消 DirectProject 待发消息后台任务失败:{error}"))? +} + #[tauri::command] pub(crate) async fn subscribe_direct_project_thread( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e0ceeb6da..1d61f47ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2619,7 +2619,8 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, - chat_with_game_creator_direct_codex, + enqueue_direct_codex_turn, + remove_direct_project_pending_turn, preflight_web_game_creation, cancel_direct_codex_turn, select_game_creator_reasoning_effort, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index f0c1c847a..571227caa 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -946,7 +946,7 @@ function directCodexDiagnosticFailureParts( return { stageLabel, summary, hint, retryable: retryable === 'true' }; } -/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, @@ -962,10 +962,10 @@ function directCodexDiagnosticFailureDetail(message: string) { } /** - * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * **请求被拒**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 * * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 - * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 哪一步",而请求被拒是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 */ export function projectRuntimeVisibleRejectionError( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f8b9866b5..d8e02903e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -163,7 +163,7 @@ export function DirectProjectChatView({ turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 - // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。接单窗口里还没有这一轮的条目, + // 只可能是最后一轮:原生 `turnRunning` 只赋给最新一轮。入队窗口里还没有这一轮的条目, // 于是这里是 0,卡片只报"正在处理"、不读秒(宿主开始事件一到就开始读秒)。 const latestTurn = directTurns.at(-1) ?? null; const activeTurnStartedAt = diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index bbee4e368..232197e32 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -37,10 +37,10 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - directTurnRejectionNotice, - directTurnRejectionNoticeMessageId, - directTurnUnrecognizedRejectionNoticeText, - readDirectTurnRejection, + directTurnEnqueueFailureNotice, + directTurnEnqueueFailureNoticeMessageId, + directTurnUnrecognizedEnqueueFailureNoticeText, + readDirectTurnEnqueueFailure, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { @@ -103,7 +103,7 @@ export type DirectProjectChatControllerProps = { * 传输层(三条通道,前端各拉各的) * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) - * C 本地: 前端自己造(忙态、拒单提示、终止说明)——**不造用户消息** + * C 本地: 前端自己造(忙态、入队失败提示、终止说明)——**不造用户消息** * ▼ * 前端 * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) @@ -119,29 +119,29 @@ export type DirectProjectChatControllerProps = { * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** - * 活跃与否的**唯一**判据(接单时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 - * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与拒单提示两种。 - * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令接单化」的后续更新), + * - 本地说明:只存在于本次会话,`projectPath` 变化即清空;只有壳层 `announce` 与入队失败提示两种。 + * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令入队化」), * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 * 任何条目,只有 composer 忙态与状态行): - * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主认领之前不存在。 - * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 走完接单前的检查 → 接单(登记占用 + - * append `turn.started`)→ 落盘用户条目 → spawn 整轮 → **立刻返回**。命令返回只说明接单成立, - * 整轮的结果不再从这条通道回来;拒单则返回结构化的 typed 错误。 + * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在。 + * 2. `invoke('enqueue_direct_codex_turn')`:Rust 走完入队检查 → **入队**(排进待发消息队列)→ + * **立刻返回**。命令返回只说明入队成立;放行(登记占用 + `turn.started` + 离开队列)由宿主 + * 在队首就绪时自己完成,整轮的结果不再从这条通道回来,入队失败则返回结构化的 typed 错误。 * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 - * 的用户气泡**第一次**出现在聊天区(发点在接单之后、起 codex 之前)。 + * 的用户气泡**第一次**出现在聊天区(发点在放行之后、起 codex 之前)。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**没接单**时放掉忙态并出队(权限被拒那种 - * 「本轮从未发出但要继续出队」的路径也在这里收口),接单成立的那一轮交给第 3 / 7 步。 + * 8. 命令收尾(`finally`):刷新清单;只在**入队失败**时放掉忙态并出队(权限被拒那种 + * 「本轮从未发出但要继续出队」的路径也在这里收口),入队成立的那一轮交给第 3 / 7 步。 * * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 @@ -213,7 +213,7 @@ export function useDirectProjectChatController({ const completionPendingRef = useRef(false); /** * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, - * 接单返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 + * 入队返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 */ const pendingRunAnalyticsRef = useRef<{ clientTurnId: string; @@ -266,12 +266,12 @@ export function useDirectProjectChatController({ }, [turnBusy, currentTurnRunning, completedTurnCount]); /** - * 回合终态是队列放行与埋点结算的唯一出口(接单被拒走命令那条路,见 `startTurn` 的收尾)。 + * 回合终态是队列放行与埋点结算的唯一出口(入队失败走命令那条路,见 `startTurn` 的收尾)。 * * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 * - * TODO(发送队列):这条队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。 + * TODO(发送队列):这条队列整体挪到宿主的待发消息队列(放行由 Thread Manager 自己驱动)。 */ useEffect(() => { if (!enabled) { @@ -318,10 +318,10 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主认领这一轮(`turn.started` 落进 reducer) + * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主放行这一轮(`turn.started` 落进 reducer) * 或这一轮明确没成立时放掉。 * - * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:接单化之后命令只等到接单就返回, + * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:入队化之后命令只等到入队就返回, * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 */ function beginTurnBusy() { @@ -336,8 +336,8 @@ export function useDirectProjectChatController({ } /** - * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,接单返回时就结算 - * 会变成一次空操作(宿主找不到候选,静默丢弃)。拒单那一轮没有候选,句柄由 `runTurn` 自己清掉。 + * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 + * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 */ function settlePendingRunAnalytics() { const pending = pendingRunAnalyticsRef.current; @@ -516,7 +516,7 @@ export function useDirectProjectChatController({ /** * 发起一轮 DirectProject 回合:写权限门 + invoke + 本地忙态与错误收尾。 * - * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"接单窗口期聊天区没有这一 + * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"入队窗口期聊天区没有这一 * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ function startTurn(input: DirectProjectTurnInput) { @@ -582,14 +582,14 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - // 收尾分两种:接单成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` - // 时出队与结算,见上面两个 effect);没成立的那些路径没有任何事件会来,只能在这里收口。 + // 收尾分两种:入队成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` + // 时出队与结算,见上面两个 effect);入队失败的那些路径没有任何事件会来,只能在这里收口。 if (!turnAccepted) { // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 endTurnBusy(); // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 - // 拒单时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 + // 入队失败时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 if ( queueAdvance || (invoked && projectPathRef.current === nextProjectPath) @@ -606,7 +606,7 @@ export function useDirectProjectChatController({ nextProjectPath: string, input: DirectProjectTurnInput, ): Promise { - // 命令返回 `Ok` 只说明**接单成立**:整轮怎么收场只由 `turn.completed` 事件回答。 + // 命令返回 `Ok` 只说明**入队成立**:整轮怎么收场只由 `turn.completed` 事件回答。 // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 let turnAccepted = false; try { @@ -619,7 +619,7 @@ export function useDirectProjectChatController({ clientTurnId: input.clientTurnId, runAnalytics, }; - await invoke('chat_with_game_creator_direct_codex', { + await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, userItem: input.userItem, @@ -629,11 +629,11 @@ export function useDirectProjectChatController({ turnAccepted = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { - // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 - // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 - const rejection = readDirectTurnRejection(error); - // 只有结构化拒单能证明"这一轮没接单"(拒单不产生回合事件、也就不会有埋点候选),句柄才 - // 只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在接单之后,那时必须留着句柄等 + // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 + // 只剩"入队失败"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 + const rejection = readDirectTurnEnqueueFailure(error); + // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), + // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 if ( rejection && @@ -642,9 +642,9 @@ export function useDirectProjectChatController({ pendingRunAnalyticsRef.current = null; } if (rejection) { - const notice = directTurnRejectionNotice(rejection); + const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { - // 认得的前置条件 / 参数类拒单:写成与用户消息同级的提示,不占状态行、不写运行错误、 + // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 // 也不上报(用户自己就能改,上报只会变成噪声)。 if (projectPathRef.current === nextProjectPath) { onRuntimeError(''); @@ -652,7 +652,7 @@ export function useDirectProjectChatController({ role: 'assistant', text: notice, runtimeOwned: true, - messageId: directTurnRejectionNoticeMessageId( + messageId: directTurnEnqueueFailureNoticeMessageId( directCodexConversationMessageId(input.clientTurnId, 'user'), ), updatedAt: Date.now(), @@ -662,12 +662,12 @@ export function useDirectProjectChatController({ } } if (projectPathRef.current !== nextProjectPath) return false; - // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 + // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 拒单文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; - // 拒单不带阶段标签(这一轮没有开始),所以走拒单那一份映射。 + // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; + // 入队失败不带阶段标签(这一轮没有开始),所以走入队失败那一份映射。 const visibleMessage = rejection - ? directTurnUnrecognizedRejectionNoticeText(rejection) + ? directTurnUnrecognizedEnqueueFailureNoticeText(rejection) : projectRuntimeVisibleError( error instanceof Error ? error.message : String(error), '陶泥儿智能创作', @@ -675,15 +675,15 @@ export function useDirectProjectChatController({ ); if (projectPathRef.current !== nextProjectPath) return false; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 - // 本轮最后一条条目(唯一来源)。**拒单没有这条出口**——拒单不产生回合事件,聊天里那条乐观 - // 用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 - // 非结构化错误同样不写聊天:它可能发生在接单之后,说明由事件流负责。 + // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 + // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 + // 非结构化错误同样不写聊天:它可能发生在放行之后,说明由事件流负责。 if (rejection) { appendLocalMessage({ role: 'assistant', text: visibleMessage, runtimeOwned: true, - messageId: directTurnRejectionNoticeMessageId( + messageId: directTurnEnqueueFailureNoticeMessageId( directCodexConversationMessageId(input.clientTurnId, 'user'), ), updatedAt: Date.now(), diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index ed5df6dce..b884211e9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -15,8 +15,8 @@ import type { * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → - * 宿主认领这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。接单化之后命令只 - * 等到接单就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 + * 宿主放行这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。入队化之后命令只 + * 等到入队就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 5547facbf..62d80f344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -2,7 +2,7 @@ import type { ChatMessage } from '../../../../app/types'; import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; -import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; +import type { DirectTurnEnqueueFailure } from '../generated/DirectTurnEnqueueFailure'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; @@ -63,14 +63,14 @@ export function directCodexConversationMessageId( } /** - * 命令的**拒单**载荷(Rust 侧 `DirectTurnRejection`):结构化变体 + 宿主生成的文案。 + * 命令的**入队失败**载荷(Rust 侧 `DirectTurnEnqueueFailure`):结构化变体 + 宿主生成的文案。 * * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 * `error.type`——文案是给人看的,不参与任何判断。 */ -export function readDirectTurnRejection( +export function readDirectTurnEnqueueFailure( error: unknown, -): DirectTurnRejection | null { +): DirectTurnEnqueueFailure | null { if (!error || typeof error !== 'object') return null; const candidate = error as { error?: unknown; message?: unknown }; const variant = candidate.error; @@ -78,12 +78,12 @@ export function readDirectTurnRejection( const type = (variant as { type?: unknown }).type; if (typeof type !== 'string' || !type) return null; if (typeof candidate.message !== 'string') return null; - return candidate as DirectTurnRejection; + return candidate as DirectTurnEnqueueFailure; } /** - * 认得的拒单(前置条件不满足 / 用户参数无效)→ 与用户消息同级的提示文案;认不得的返回 `null`, - * 由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 + * 认得的入队失败(前置条件不满足 / 用户参数无效 / 队列已满)→ 与用户消息同级的提示文案; + * 认不得的返回 `null`,由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 * * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 @@ -95,49 +95,47 @@ export function readDirectTurnRejection( * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 * 调用方当成"有提示")。 */ -export function directTurnRejectionNotice( - rejection: DirectTurnRejection, +export function directTurnEnqueueFailureNotice( + failure: DirectTurnEnqueueFailure, ): string | null { - switch (rejection.error.type) { + switch (failure.error.type) { case 'clientTurnIdMissing': case 'clientTurnIdMalformed': + case 'queueFull': case 'turnAlreadyRunning': case 'projectRootUnanchored': case 'projectRootUnusable': case 'permissionRejected': case 'inputRejected': case 'contentEmpty': - return rejection.message.trim() || null; + return failure.message.trim() || null; default: return null; } } /** - * 拒绝提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, * 两条通道永远不会合并成一条。 */ -export function directTurnRejectionNoticeMessageId(userItemId: string) { +export function directTurnEnqueueFailureNoticeMessageId(userItemId: string) { return `${userItemId}:rejected`; } /** - * **认不出的**拒单(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 + * **认不出的**入队失败(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 * - * 这两类拒单不产生 `turn.completed`(拒单没有接单),而本地也不再造用户气泡,所以这一轮在聊天区里 - * 本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。它带自己的身份 - * (`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 + * 这两类失败不产生 `turn.completed`(入队失败的这一轮从未成立),而本地也不再造用户气泡,所以 + * 这一轮在聊天区里本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。 + * 它带自己的身份(`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 * - * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` + * 文案不能原样用宿主给的 `message`:这类失败的 `message` 是宿主的收口文案(带 `stage=` / `code=` * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, * 绝不把内部字段塞进聊天。 */ -export function directTurnUnrecognizedRejectionNoticeText( - rejection: DirectTurnRejection, +export function directTurnUnrecognizedEnqueueFailureNoticeText( + failure: DirectTurnEnqueueFailure, ): string { - return projectRuntimeVisibleRejectionError( - rejection.message, - '陶泥儿智能创作', - ); + return projectRuntimeVisibleRejectionError(failure.message, '陶泥儿智能创作'); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 199a335bb..85d58327e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -89,7 +89,7 @@ export type DirectThreadChatState = { * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 - * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(接单后 + * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(放行后 * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, * 批量到达也一样看得见。 */ @@ -324,7 +324,7 @@ export function reduceDirectThreadEvent( const eventAt = readDirectThreadEventAt(event); // 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、 // 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合, - // 哪怕它落在同一秒。开始事件由 Thread Manager 在接单时发一次,线上不再有同一轮的 + // 哪怕它落在同一秒。开始事件由 Thread Manager 在放行时发一次,线上不再有同一轮的 // 重复起点,所以这里不做"保留第一次"的兼容。 const turnStartedAt = eventAt; // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 1ab3d433e..5c55c19b9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -6,7 +6,7 @@ * * **回合归属只认身份**:开口用户条目的 canonical `itemId`(`direct-codex:{clientTurnId}:user`) * 就是这一轮的回合身份,本轮用户条目与失败说明按它归进同一轮。本地只保留"说明"类消息 - * (拒单提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 + * (入队失败提示、壳层 `announce`),它们不是回合条目,也不参与回合身份。 */ import type { ChatMessage } from '../../../../app/types'; @@ -64,11 +64,11 @@ export type DirectChatBlock = * 回显条目的 `at` 还是宿主落盘 / 观测时间(晚于用户真实发送)。 * * 这里曾经有过第三个态 `awaiting-start`(本地已发出、宿主还没认领):它只服务本地乐观用户气泡。 - * 气泡已按"回合只认宿主条目"删除,所以"接单窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 - * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令接单化」的后续更新)。 + * 气泡已按"回合只认宿主条目"删除,所以"入队窗口"不再是一种展示态——窗口期聊天区里没有这一轮的 + * 任何条目,只有 composer 的忙态与状态行(见 ADR「DirectProject命令入队化」)。 * * 两个容易读错的地方: - * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到接单,宿主确认这一轮靠的是开始事件; + * - 命令返回 **≠** 这一轮结束了:`invoke` 只等到入队,宿主确认这一轮靠的是开始事件; * 所以"命令还没回来"不是任何展示态依据,只有显式事件是。 * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 * 缓存),它们必须落 `finished`。 @@ -105,7 +105,7 @@ export type DirectChatTurn = { type DirectChatTurnEntries = { key: string; entries: DirectChatEntry[]; - /** 本地说明(拒单提示、壳层 `announce`):不是回合条目,按挂点归组。 */ + /** 本地说明(入队失败提示、壳层 `announce`):不是回合条目,按挂点归组。 */ notices: ChatMessage[]; /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ nativeRunning: boolean; @@ -157,7 +157,7 @@ function blockFromLocalMessage( ): DirectChatBlock | null { const text = message.text.trim(); if (!text) return null; - // 本地消息只剩"说明"一种(拒单提示、壳层 `announce`):用户消息一律来自宿主条目。 + // 本地消息只剩"说明"一种(入队失败提示、壳层 `announce`):用户消息一律来自宿主条目。 return { kind: 'assistant', key: message.messageId ?? `local:${index}`, @@ -198,7 +198,7 @@ function directEntryTurnKey(entry: DirectChatEntry): string { * 其余条目(工具、思考、正文)不带身份,跟着当前回合走。 * * 本地消息只剩说明一种,用户消息一律来自宿主条目(本地乐观气泡已删,见 ADR「DirectProject命令 - * 接单化」的后续更新):带身份的说明(拒单提示,id 形如 `…:user:rejected`)说的是"这一轮从没 + * 入队化」的后续更新):带身份的说明(入队失败提示,id 形如 `…:user:rejected`)说的是"这一轮从没 * 成立过",不属于任何回合,自己在会话末尾开一组;不带头身份的是壳层 `announce`,挂到当前回合末 * 尾。同身份的本地消息不重复渲染:条目赢。 * @@ -269,7 +269,7 @@ export function buildDirectChatTurns({ // 本地不再造用户消息(乐观气泡已删):万一还来了一条,既不进聊天区、也不开回合。 if (message.role === 'user') return; if (message.messageId && entryIds.has(message.messageId)) return; - // 带身份的本地说明(拒单提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 + // 带身份的本地说明(入队失败提示)说的是"这一轮从没成立过":它不属于任何回合,也不能按位置挂进 // 上一轮(那会被读成上一轮的问题)。它自己在会话末尾开一组提示:没有用户条目的回合不会凭空 // 多出一条耗时文案(`startedAt` 拿不到时终态文案整条隐藏)。 if (message.messageId) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts index e9b8f2355..a7040d769 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts @@ -3,7 +3,7 @@ /** * 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 * - * 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 + * 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 */ export type DirectCodexFailureStage = | 'art-preparation' diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts index b76ba3800..1c39e2058 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts @@ -2,7 +2,7 @@ import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; /** - * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 * * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts index 90f8b8247..c6c619049 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectQueueRemovalReason.ts @@ -3,7 +3,7 @@ /** * 一条待发消息离开队列的原因。 * - * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合 + * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 * (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 */ export type DirectQueueRemovalReason = 'cancelled' | 'dispatched'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 630a41e95..55c0b6a94 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -35,7 +35,7 @@ export type DirectThreadEvent = | { type: 'turn.started'; /** - * 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 * `turn/start` 的时刻)。 */ at?: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts index b0ad58f44..83f2fe79b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts @@ -3,6 +3,6 @@ /** * 宿主等不到模型回执时,撞的是哪一条上限。 * - * 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 + * 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 */ export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts similarity index 85% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts index ae3ce8aa9..3e3496ab8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts @@ -2,13 +2,13 @@ import type { DirectTurnError } from './DirectTurnError'; /** - * 拒单载荷:命令边界交给前端的**结构化拒绝**。 + * 入队失败载荷:命令边界交给前端的**结构化入队失败**。 * * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 */ -export type DirectTurnRejection = { +export type DirectTurnEnqueueFailure = { /** * 结构化变体:界面按 `error.type` 分流,不解析文案。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts index 7e14e32c0..94d69d66a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -14,6 +14,7 @@ export type DirectTurnError = existingInvocationId: string; incomingInvocationId: string; } + | { type: 'queueFull'; limit: number } | { type: 'projectRootUnanchored'; cause: string } | { type: 'projectRootUnusable' } | { type: 'permissionRejected'; policyDetail: string } diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index c1a1ef1a1..0d6074e4e 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -462,8 +462,8 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); }); - test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { - // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + test('请求被拒的文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而请求被拒是"这一轮没有开始":阶段只会是默认值, // 套上去会把没发生的事讲成发生了。 expect( projectRuntimeVisibleRejectionError( @@ -481,7 +481,7 @@ describe('Agent Runtime Provider 状态投影', () => { ), ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; - // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + // 目录锚不定的请求被拒在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 expect( projectRuntimeVisibleRejectionError( '无法锚定 Direct 调用项目目录:拒绝访问', diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 9008e4cb9..51c3db79e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -400,7 +400,7 @@ export function registerChatComposerControlTests() { // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, // 整轮只 invoke 一次、埋点只结算一次。 const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { throw new Error('authentication-required'); }, }); @@ -414,22 +414,22 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); const attempts = invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ); expect(attempts).toHaveLength(1); expect(refresh).not.toHaveBeenCalled(); - // 这一轮没有接单,不会有回合终态事件来驱动结算:埋点一次也不结算。 + // 这一轮没有入队,不会有回合终态事件来驱动结算:埋点一次也不结算。 expect( invoke.mock.calls.filter( ([command]) => command === 'settle_direct_run_analytics', ), ).toHaveLength(0); - // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 + // 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 await waitFor(() => { expect( within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), @@ -441,10 +441,10 @@ export function registerChatComposerControlTests() { }); it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { - // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 + // 宿主 / 环境事实的入队失败没有放行、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在 // (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。 const { surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { throw { error: { type: 'environmentNotReady', @@ -470,7 +470,7 @@ export function registerChatComposerControlTests() { 'direct-codex-failure', ); expect(conversation.textContent ?? '').not.toContain('stage='); - // 拒单没接单:忙碌态要放掉,用户能直接重发。 + // 入队失败没有成立:忙碌态要放掉,用户能直接重发。 await waitFor(() => { expect( within(surface).getByRole('button', { name: '发送' }), @@ -480,8 +480,8 @@ export function registerChatComposerControlTests() { it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const { invoke, surface, harness } = await openDirectCodexSurface({ - // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 - chat_with_game_creator_direct_codex: () => { + // 命令只入队(放行由 Thread Manager 自己完成),随后立刻返回。 + enqueue_direct_codex_turn: () => { harness.emitDirectThreadEvents({ type: 'turn.started' }); return Promise.resolve(null); }, @@ -490,7 +490,7 @@ export function registerChatComposerControlTests() { await submitDirectTurn(surface, composer, '第一条消息'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('第一条消息'), ); }); @@ -528,7 +528,7 @@ export function registerChatComposerControlTests() { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); - // 队列放行听的是**回合终态**,不是命令返回:接单之后命令早就回来了。 + // 队列放行听的是**回合终态**,不是命令返回:入队之后命令早就回来了。 act(() => { harness.emitDirectThreadEvents({ type: 'turn.completed', @@ -537,13 +537,13 @@ export function registerChatComposerControlTests() { }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('第三条消息'), ); }); // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 const sentTexts = invoke.mock.calls - .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .filter(([command]) => command === 'enqueue_direct_codex_turn') .map(([, args]) => directTurnInputText(args)); expect(sentTexts).toEqual(['第一条消息', '第三条消息']); @@ -562,7 +562,7 @@ export function registerChatComposerControlTests() { const pending: Array<{ resolve: (value: string) => void }> = []; let userItemId = ''; const { invoke, surface, harness } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: (args) => { + enqueue_direct_codex_turn: (args) => { userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; return new Promise((resolve) => { pending.push({ resolve }); @@ -576,7 +576,7 @@ export function registerChatComposerControlTests() { // 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。 await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('窗口期的消息'), ); }); @@ -635,7 +635,7 @@ export function registerChatComposerControlTests() { null; const { surface } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: ( + enqueue_direct_codex_turn: ( args: Record | undefined, ) => { // 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来, @@ -690,14 +690,14 @@ export function registerChatComposerControlTests() { }); it('keeps the composer moving when the host fails the turn right after accepting it', async () => { - // 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 + // 落盘失败发生在**放行之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 // 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。 const seen: string[] = []; let harness: ReturnType | null = null; const { surface } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: ( + enqueue_direct_codex_turn: ( args: Record | undefined, ) => { const text = directTurnInputText(args); @@ -759,7 +759,7 @@ export function registerChatComposerControlTests() { deferredPolicies.push(resolve); }); }, - chat_with_game_creator_direct_codex: () => + enqueue_direct_codex_turn: () => new Promise((resolve) => { pending.push({ resolve }); }), @@ -790,7 +790,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('后面那条'), ); }); @@ -809,7 +809,7 @@ export function registerChatComposerControlTests() { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface, harness } = await openDirectCodexSurface( { - chat_with_game_creator_direct_codex: () => + enqueue_direct_codex_turn: () => new Promise((resolve) => { pending.push({ resolve }); }), @@ -831,7 +831,7 @@ export function registerChatComposerControlTests() { expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(0); @@ -843,13 +843,13 @@ export function registerChatComposerControlTests() { }); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('恢复后排队的消息'), ); }); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); @@ -860,9 +860,9 @@ export function registerChatComposerControlTests() { it('terminates the running turn and returns the composer to the idle state', async () => { const { invoke, path, surface, harness } = await openDirectCodexSurface({ - // 命令只接单:接单成立(开始事件已由 Thread Manager 下发)后它立刻返回,"这一轮还在跑" + // 命令只入队:入队成立(放行与开始事件都由 Thread Manager 自己完成)后它立刻返回,"这一轮还在跑" // 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。 - chat_with_game_creator_direct_codex: () => { + enqueue_direct_codex_turn: () => { harness.emitDirectThreadEvents({ type: 'turn.started' }); return Promise.resolve(null); }, @@ -876,7 +876,7 @@ export function registerChatComposerControlTests() { await submitDirectTurn(surface, composer, '做一个小游戏'); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expectDirectTurnWithText('做一个小游戏'), ); }); @@ -944,7 +944,7 @@ export function registerChatComposerControlTests() { // 只影响后续回合:当前没有发出任何新一轮直接对话。 expect( invoke.mock.calls.some( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toBe(false); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 9fcfbbf19..078fe66a0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1376,7 +1376,7 @@ export function registerHomeProjectCreationTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { chatHarness.completeDirectThreadTurn({ turnId: String(args?.clientTurnId ?? ''), prompt: '你好,今天多少号', @@ -1457,7 +1457,7 @@ export function registerHomeProjectCreationTests() { planning: false, projectsRoot: null, }); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), @@ -1474,7 +1474,7 @@ export function registerHomeProjectCreationTests() { invoke.mock.calls .map(([command]) => String(command)) .filter((command) => command.includes('direct_codex')), - ).toEqual(['chat_with_game_creator_direct_codex']); + ).toEqual(['enqueue_direct_codex_turn']); // 首页那条直连命令没有出现,界面也已经交给项目对话面板。 expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull(); @@ -1525,7 +1525,7 @@ export function registerHomeProjectCreationTests() { }); return '角色参考游戏'; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { chatHarness.completeDirectThreadTurn({ turnId: String(args?.clientTurnId ?? ''), prompt: '按这个角色做游戏', @@ -1578,32 +1578,29 @@ export function registerHomeProjectCreationTests() { // 首轮需求由 DirectProject 聊天容器认领:它要先读到项目清单(项目身份就绪)才发送, // 所以这里等一等,不能在「工作台已出现」的同一帧断言。 await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath: automaticProjectPath, - creationType: 'game', - clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), - userItem: { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', - role: 'user', - // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 - content: [ - { type: 'input_text', text: '按这个角色做游戏' }, - { - type: 'agc_attachment_reference', - name: '角色参考.png', - mediaType: 'image/png', - size: attachment.size, - localPath: 'assets/uploads/reference.png', - status: 'imported', - }, - ], - }, + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { + projectPath: automaticProjectPath, + creationType: 'game', + clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 + content: [ + { type: 'input_text', text: '按这个角色做游戏' }, + { + type: 'agc_attachment_reference', + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }, - ); + }); }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1617,11 +1614,11 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(2); }); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { projectPath: automaticProjectPath, clientTurnId: expect.any(String), analyticsAttemptId: expect.any(String), @@ -1634,7 +1631,7 @@ export function registerHomeProjectCreationTests() { }); // 第二次 invoke 就是这条后续消息:回合入参里没有客户端投影文本可供筛选。 const followUpPayload = invoke.mock.calls - .filter(([command]) => command === 'chat_with_game_creator_direct_codex') + .filter(([command]) => command === 'enqueue_direct_codex_turn') .at(-1)?.[1] as Record | undefined; // 后续这一轮没有附件:canonical content 里只有文本 part。 expect( @@ -1929,7 +1926,7 @@ export function registerHomeProjectCreationTests() { '本轮用户附件', ); expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.anything(), ); expect(invoke).not.toHaveBeenCalledWith( @@ -2069,7 +2066,7 @@ export function registerHomeProjectCreationTests() { 'references/角色参考.png', ); expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.anything(), ); expect(invoke).not.toHaveBeenCalledWith( @@ -2179,7 +2176,7 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath }); return currentManifest; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { return directTurn; } return runtimeHarness.invoke(command, args); @@ -2203,7 +2200,7 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', + 'enqueue_direct_codex_turn', expect.objectContaining({ projectPath }), ); expect(runtimeHarness.listen).toHaveBeenCalledWith( @@ -2269,7 +2266,7 @@ export function registerHomeProjectCreationTests() { }); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); } @@ -2338,7 +2335,7 @@ export function registerHomeProjectCreationTests() { 'DirectProject must not use browser conversation writer', ); } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { const clientTurnId = String(args?.clientTurnId ?? ''); persistedMessages.push( { @@ -2372,26 +2369,23 @@ export function registerHomeProjectCreationTests() { ); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath, - clientTurnId: expect.any(String), - analyticsAttemptId: expect.any(String), - userItem: { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '继续修改已有项目' }], - }, + expect(invoke).toHaveBeenCalledWith('enqueue_direct_codex_turn', { + projectPath, + clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '继续修改已有项目' }], }, - ); + }); }); // 首轮只发一轮 DirectProject 回合:其余命令都由上面的 stub 逐条接住, // 未列出的命令(含浏览器侧会话写入)在 stub 里直接抛错。 expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); @@ -2427,7 +2421,7 @@ export function registerHomeProjectCreationTests() { policy: { deniedCommands: [], confirmCommands: [] }, }; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { const clientTurnId = String(args?.clientTurnId ?? ''); expect(args).toEqual({ projectPath, @@ -2511,7 +2505,7 @@ export function registerHomeProjectCreationTests() { ).not.toBeNull(); expect( invoke.mock.calls.filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', + ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); }); @@ -3387,7 +3381,7 @@ export function registerRecentProjectsTests() { manifest, }; } - if (command === 'chat_with_game_creator_direct_codex') { + if (command === 'enqueue_direct_codex_turn') { return '收到,开始搭建。'; } return supervisorHarness.invoke(command, args); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 36050745d..45745ed82 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,9 +1,9 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { directCodexPolicyRetryInput, - directTurnRejectionNotice, - directTurnUnrecognizedRejectionNoticeText, - readDirectTurnRejection, + directTurnEnqueueFailureNotice, + directTurnUnrecognizedEnqueueFailureNoticeText, + readDirectTurnEnqueueFailure, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { createGameCreationAppManifest, @@ -22,7 +22,7 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { - // 拒单是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 + // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 const concurrent = { error: { type: 'turnAlreadyRunning' as const, @@ -31,34 +31,36 @@ export function registerProjectConversationTests() { }, message: '同一轮消息仍在处理中', }; - expect(readDirectTurnRejection(concurrent)?.error.type).toBe( + expect(readDirectTurnEnqueueFailure(concurrent)?.error.type).toBe( 'turnAlreadyRunning', ); - expect(directTurnRejectionNotice(concurrent)).toBe('同一轮消息仍在处理中'); + expect(directTurnEnqueueFailureNotice(concurrent)).toBe( + '同一轮消息仍在处理中', + ); // 认得的参数 / 前置条件类都给同级提示。 expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'contentEmpty' }, message: '聊天内容不能为空', }), ).toBe('聊天内容不能为空'); // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'environmentNotReady', detail: '连不上 app-server' }, message: '环境未就绪', }), ).toBeNull(); expect( - directTurnRejectionNotice({ + directTurnEnqueueFailureNotice({ error: { type: 'hostStateUnavailable', detail: '账本损坏' }, message: '宿主状态取不到', }), ).toBeNull(); - // 认不出的拒单在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / + // 认不出的入队失败在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 expect( - directTurnUnrecognizedRejectionNoticeText({ + directTurnUnrecognizedEnqueueFailureNoticeText({ error: { type: 'environmentNotReady', detail: '连不上 app-server' }, message: 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', @@ -68,19 +70,21 @@ export function registerProjectConversationTests() { ); // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 expect( - directTurnUnrecognizedRejectionNoticeText({ + directTurnUnrecognizedEnqueueFailureNoticeText({ error: { type: 'hostStateUnavailable', detail: '账本损坏' }, message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', }), ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); // 不是这份结构(旧字符串、Error、裸对象)一律不认。 expect( - readDirectTurnRejection('codex-app-server-error:unauthorized'), + readDirectTurnEnqueueFailure('codex-app-server-error:unauthorized'), ).toBeNull(); - expect(readDirectTurnRejection(new Error('boom'))).toBeNull(); - expect(readDirectTurnRejection({ error: {}, message: 'x' })).toBeNull(); + expect(readDirectTurnEnqueueFailure(new Error('boom'))).toBeNull(); expect( - readDirectTurnRejection({ error: { type: 'contentEmpty' } }), + readDirectTurnEnqueueFailure({ error: {}, message: 'x' }), + ).toBeNull(); + expect( + readDirectTurnEnqueueFailure({ error: { type: 'contentEmpty' } }), ).toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 2b8982e8e..4b385780e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -667,7 +667,7 @@ describe('DirectProject 聊天 reducer', () => { }); it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => { - // 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮 + // 宿主在放行时**只发一次** `turn.started`(Thread Manager 的认领动作),线上不存在"同一轮 // 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer // 按事件顺序照实收下,不替它编一个更早的起点。 const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ @@ -680,7 +680,7 @@ describe('DirectProject 聊天 reducer', () => { it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => { // 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次 - // consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。 + // consume 里开始并结束(放行后立刻失败),那时下降沿永远不会出现。 const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'turn.started', at: 1_000_000 }), event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }), diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f3ae0325d..06ce3bd38 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -323,7 +323,7 @@ describe('DirectProject 聊天分区', () => { }); it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => { - // 接单窗口(本地已发出、宿主还没认领)不再是展示态:这一轮在聊天区里根本不存在, + // 入队窗口(本地已发出、宿主还没放行)不再是展示态:这一轮在聊天区里根本不存在, // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。 const windowTurns = buildDirectChatTurns({ entries: [] }); expect(windowTurns).toEqual([]); From 0534e9c6780b45bd311fa95f4c4c45b49b594c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:20:37 +0800 Subject: [PATCH 05/28] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E9=98=9F=E5=88=97?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E4=BA=8B=E4=BB=B6=E6=8A=95=E5=BD=B1=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E9=80=80=E5=BD=B9=E6=9C=AC=E5=9C=B0=E9=98=9F?= =?UTF-8?q?=E5=88=97=E6=9C=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 conversation/directPendingTurns.ts:入队/移除两个纯函数,不判上限、不排期、不排序 - reducer 新增 queue.enqueued / queue.removed 分支,折成 pendingTurns 投影 - chip 文案派生搬到 pendingTurnChipLabel.ts,删除 chatComposerQueue.ts 整套本地队列实现 - controller 退役 queuedTurns / queueSequenceRef / completionPendingRef / busyBaselineTurnCountRef 与看门 counts effect;取消 chip 改调 remove_direct_project_pending_turn 按 typed 结果说话 - 埋点句柄按 clientTurnId 存表,回合终态按本轮开口条目身份结算 - turnBusy 改名 commandInFlight 并收窄成 IPC 在飞;忙态 = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空 - 提交改成等命令的入队结果:用户自己能改的入队失败保留草稿,其余按已交出处理 - 测试:队列投影单测、入队只发一次 IPC、IPC 在飞挡住第二次提交、满队保留草稿、取消已放行条目 - 文档:实施计划标注第 0–3 步落地并补第 3 步细则,ADR 的埋点与退役面口径改成落地后的写法 --- .../chat/DirectProjectChatView.tsx | 17 +- .../ComposerControls.tsx | 52 ++- .../DirectProjectComposer.tsx | 32 +- .../chatComposerQueue.ts | 99 ---- .../pendingTurnChipLabel.ts | 33 ++ .../useDirectProjectChatController.ts | 442 ++++++++---------- .../controller/useDirectProjectTurnStatus.ts | 48 +- .../chat/conversation/directPendingTurns.ts | 49 ++ .../chat/conversation/directThreadChat.ts | 58 ++- .../tests/appSurface/chat-composer.suite.ts | 437 ++++++++++------- .../tests/directProjectTurnStatus.test.ts | 50 +- .../tests/directThreadChat.test.ts | 128 +++++ ...命令入队化与待发消息队列归宿主-2026-09-24.md | 14 +- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 38 +- 14 files changed, 887 insertions(+), 610 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index d8e02903e..f1c00df6d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -125,8 +125,9 @@ export function DirectProjectChatView({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, + commandInFlight, composerNotice, directEntries, directTurnRunning, @@ -134,11 +135,10 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, - queuedTurns, + pendingTurns, startInitialTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, } = chat; @@ -156,10 +156,11 @@ export function DirectProjectChatView({ }), [directEntries, localMessages, directTurnRunning, directTurnStartedAt], ); - // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮两态。 + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / IPC 在飞 / 待发消息条数 / 最新一轮两态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns: directTurns, }); // 状态条的起点只认**未结束**的最新一轮:只有它才拿得到本轮的 `turn.started.at`(运行中读实时值)。 @@ -268,11 +269,13 @@ export function DirectProjectChatView({ projectPath={projectPath} attachmentNotice={attachmentNotice} attachmentsImporting={attachmentsImporting} - queuedTurns={queuedTurns} + pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} turnCancelling={turnCancelling} - onCancelQueuedTurn={cancelQueuedTurn} + onCancelPendingTurn={(clientTurnId) => + void cancelPendingTurn(clientTurnId) + } onCancelTurn={() => void cancelTurn()} onReferencePickerOpen={() => { // 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index dc4904ed0..3db8b8f3b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -3,7 +3,7 @@ * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 * * 这些组件只承载表现与交互;回合附件由宿主导入后插成正文芯片(正文是唯一事实源), - * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + * 待发消息队列由宿主持有(这里只是一份事件投影,文案派生见 `pendingTurnChipLabel.ts`)。 */ import { Check, @@ -30,8 +30,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; -import type { QueuedChatTurn } from './chatComposerQueue'; -import { queuedChatTurnLabel } from './chatComposerQueue'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, speechEventTranscript, @@ -41,6 +40,7 @@ import { type SpeechRecognitionLike, VOICE_INPUT_UNSUPPORTED_MESSAGE, } from './chatComposerVoice'; +import { pendingTurnChipLabel } from './pendingTurnChipLabel'; type ComposerAttachmentMenuProps = { disabled: boolean; @@ -151,38 +151,46 @@ export function ComposerAttachmentMenu({ ); } -/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +/** + * 待发消息 chip:还没放行的消息,按 FIFO 顺序展示,可单条取消。 + * + * 列表就是宿主事件流折出来的那一份(`DirectPendingTurn`),顺序与宿主认领队首的顺序一致; + * 这里不排序、不重排,也不自己决定放行。 + */ export function ComposerTurnQueue({ turns, assets, onCancel, }: { - turns: readonly QueuedChatTurn[]; + turns: readonly DirectPendingTurn[]; /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ assets: readonly GameCreationAppAssetManifestEntry[]; - onCancel: (id: string) => void; + onCancel: (clientTurnId: string) => void; }) { if (turns.length === 0) { return null; } return (
    - {turns.map((turn, index) => ( -
  1. - {index + 1} - - {queuedChatTurnLabel(turn, assets)} - - -
  2. - ))} + {turns.map((turn, index) => { + const label = pendingTurnChipLabel(turn, assets); + return ( +
  3. + + {index + 1} + + {label} + +
  4. + ); + })}
); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index c92cd74d1..97e3ae34d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -23,8 +23,8 @@ import { } from '../../../../../features/project-workspace/ResourceReferencePicker'; import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; -import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -43,10 +43,10 @@ function draftAttachmentCount(draft: { } /** - * DirectProject 输入区:队列、附件、`@` 引用输入框、模型/推理选择和发送/终止。 + * DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。 * - * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、队列和附件的事实源 - * 仍然在聊天容器里,这里只负责把用户动作交回去。 + * 模型可用性校验、语音提示这类输入区自己的瞬时状态留在这里;回合、待发消息队列和附件的事实源 + * 仍然在聊天容器里(队列更上游是宿主),这里只负责把用户动作交回去。 */ export function DirectProjectComposer({ assets, @@ -54,11 +54,11 @@ export function DirectProjectComposer({ projectPath, attachmentNotice, attachmentsImporting, - queuedTurns, + pendingTurns, composerNotice, busy, turnCancelling, - onCancelQueuedTurn, + onCancelPendingTurn, onCancelTurn, onReferencePickerOpen, onSubmit, @@ -70,11 +70,11 @@ export function DirectProjectComposer({ attachmentNotice: string; /** 附件导入进行中:导入结果还没落成芯片,这一轮先不许发出去。 */ attachmentsImporting: boolean; - queuedTurns: QueuedChatTurn[]; + pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; busy: boolean; turnCancelling: boolean; - onCancelQueuedTurn: (id: string) => void; + onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; /** * 打开 `@` 选择器前刷新一次项目清单。 @@ -84,7 +84,14 @@ export function DirectProjectComposer({ * 用户真正要看这份清单的时刻是打开选择器的这一刻,所以在这里补一次重读。 */ onReferencePickerOpen?: () => void; - onSubmit: (content: DirectCodexUserContentPart[]) => boolean; + /** + * 提交草稿:返回**是否把这条消息交出去了**。 + * + * `true` 只代表"草稿可以清"——入队成立、或已经交给权限确认流程(确认后会重跑同一份内容); + * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 + * 界面上凭空消失。 + */ + onSubmit: (content: DirectCodexUserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 @@ -180,7 +187,8 @@ export function DirectProjectComposer({ : modelReady; if (ready) { const content = composerRef.current?.getDraft().content ?? []; - if (onSubmit(content)) { + // 草稿清不清由命令的入队结果回答(可能是一次 IPC 往返):被拒的消息必须留下草稿。 + if (await onSubmit(content)) { composerRef.current?.clear(); } } @@ -192,9 +200,9 @@ export function DirectProjectComposer({ }} > item.id === turn.id)) { - return [...queue]; - } - return [...queue, turn]; -} - -/** 取队首(FIFO)。队列为空时 `next` 为 `null`,`rest` 保持空数组。 */ -export function dequeueChatTurn(queue: readonly QueuedChatTurn[]): { - next: QueuedChatTurn | null; - rest: QueuedChatTurn[]; -} { - if (queue.length === 0) { - return { next: null, rest: [] }; - } - const [next, ...rest] = queue; - return { next: next ?? null, rest }; -} - -/** 单条取消:按 id 移除,其余项保持原有顺序。 */ -export function removeQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, -): QueuedChatTurn[] { - return queue.filter((item) => item.id !== id); -} - -export function isChatTurnQueueFull(queue: readonly QueuedChatTurn[]): boolean { - return queue.length >= MAX_QUEUED_CHAT_TURNS; -} - -export function chatQueueFullNotice(): string { - return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; -} - -/** - * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`), - * 再压成单行并限长。 - * - * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip - * 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。 - */ -export function queuedChatTurnLabel( - turn: QueuedChatTurn, - assets: readonly GameCreationAppAssetManifestEntry[], -): string { - const text = directCodexContentToPromptText( - turn.userItem.content, - resourceLabelResolver(assets), - ) - .trim() - .replace(/\s+/gu, ' '); - if (!text) return '未命名消息'; - return text.length > 24 ? `${text.slice(0, 24)}…` : text; -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts new file mode 100644 index 000000000..1a3e58a9d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts @@ -0,0 +1,33 @@ +/** + * 待发消息 chip 上显示的那一行字。 + * + * 文案与真实消息用**同一个派生**(`directCodexContentToPromptText`),再压成单行并限长:chip 与 + * 用户气泡逐字一致,不会一处展开 `@` 引用、另一处露出 `@asset:…` 这种内部 id。 + * + * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开。 + */ +import type { GameCreationAppAssetManifestEntry } from '../../../../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + directCodexContentToPromptText, + resourceLabelResolver, +} from '../../../../../features/project-workspace/resourceReferences'; +import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; + +/** 单行 + 24 字上限:chip 是输入盒上的一条窄带,不展开整段需求。 */ +const PENDING_TURN_CHIP_MAX_CHARS = 24; + +export function pendingTurnChipLabel( + turn: DirectPendingTurn, + assets: readonly GameCreationAppAssetManifestEntry[], +): string { + const text = directCodexContentToPromptText( + turn.userItem.content, + resourceLabelResolver(assets), + ) + .trim() + .replace(/\s+/gu, ' '); + if (!text) return '未命名消息'; + return text.length > PENDING_TURN_CHIP_MAX_CHARS + ? `${text.slice(0, PENDING_TURN_CHIP_MAX_CHARS)}…` + : text; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 232197e32..429c71343 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -22,15 +22,6 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from '../../../../services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - type QueuedChatTurn, - removeQueuedChatTurn, -} from '../components/DirectProjectComposer/chatComposerQueue'; import { createDirectProjectTurnId, DIRECT_CODEX_AGENT_ID, @@ -48,6 +39,7 @@ import { toDirectCodexTurnAttachments, } from '../conversation/directCodexTurnAttachments'; import type { DirectCodexUserContentPart } from '../generated/DirectCodexUserContentPart'; +import type { DirectQueueRemovalOutcome } from '../generated/DirectQueueRemovalOutcome'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate'; @@ -106,9 +98,9 @@ export type DirectProjectChatControllerProps = { * C 本地: 前端自己造(忙态、入队失败提示、终止说明)——**不造用户消息** * ▼ * 前端 - * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) + * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live / pendingTurns) * ▼ - * useDirectProjectChatController 本地状态:localMessages / turnBusy / 队列 + * useDirectProjectChatController 本地状态:localMessages / IPC 在飞 / 附件与分页 * ▼ * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) * ▼ @@ -119,7 +111,8 @@ export type DirectProjectChatControllerProps = { * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是**逻辑回合** - * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);可回收事件被回收后靠 + * 活跃与否的**唯一**判据(放行时成对发出,不再镜像 Codex 原生回合);`queue.enqueued` / + * `queue.removed` 是**待发消息队列**的唯一判据(同一个 reducer 折成 chip);可回收事件被回收后靠 * `lifecycle_anchor` 保住最新一条生命周期事件。 * 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目; * 命令返回那条通道只提供横幅与诊断,不再写聊天文案。 @@ -127,26 +120,29 @@ export type DirectProjectChatControllerProps = { * 用户消息一律来自宿主条目——本地乐观气泡已删(见 ADR「DirectProject命令入队化」), * 所以"用户那句话说没说出去"只有宿主条目一个来源。 * - * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗:聊天区里没有这一轮的 + * 一次发送的时序(第 2 → 3 步之间就是「已交给宿主、宿主还没认领」的空窗:聊天区里没有这一轮的 * 任何条目,只有 composer 忙态与状态行): - * 1. 按下发送:`turnBusy=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在。 - * 2. `invoke('enqueue_direct_codex_turn')`:Rust 走完入队检查 → **入队**(排进待发消息队列)→ - * **立刻返回**。命令返回只说明入队成立;放行(登记占用 + `turn.started` + 离开队列)由宿主 + * 1. 按下发送:`commandInFlight=true`(同帧)。聊天区不动——这一轮在宿主放行之前不存在;草稿这时 + * 也先留着,清不清由命令的入队结果回答。 + * 2. `invoke('enqueue_direct_codex_turn')`:宿主走完入队检查 → **入队**(排进待发消息队列)→ + * **立刻返回**。命令返回只说明入队成立;放行(认领队首 + `turn.started` + `queue.removed`)由宿主 * 在队首就绪时自己完成,整轮的结果不再从这条通道回来,入队失败则返回结构化的 typed 错误。 - * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 - * 4. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 + * 入队成立 → 清草稿;入队失败里"用户自己就能改"的那些 → **草稿原样留着**,另在聊天里补一条同级提示。 + * 3. `queue.enqueued`:chip 出现(队列在宿主,界面只是投影)。这一刻线程若正空闲,宿主已经在同一次 + * 认领里放行,`queue.removed{dispatched}` 与 `turn.started` 会在同一批 consume 里到达。 + * 4. `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 5. `item.completed`(本轮用户条目下发):同身份条目已在历史里就合并进去,否则进 `live`。这是这一轮 * 的用户气泡**第一次**出现在聊天区(发点在放行之后、起 codex 之前)。 - * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 - * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, + * 6. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 + * 7. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上, * 收口计数 +1;带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。 - * 7. **回合终态**(第 6 步的收口计数变化):结算本轮埋点 → 放行发送队列,顺序固定在这一处。 - * 8. 命令收尾(`finally`):刷新清单;只在**入队失败**时放掉忙态并出队(权限被拒那种 - * 「本轮从未发出但要继续出队」的路径也在这里收口),入队成立的那一轮交给第 3 / 7 步。 + * 8. **回合终态**(第 7 步的收口计数变化):按本轮身份结算埋点。**队列不在这里推进**——下一条什么时候 + * 走由宿主在占用释放时自己踢。 * - * 状态变量归属:reducer 的回合字段、收口计数与 `history` / `live` 只由 `directThreadChat.ts` 写; - * 本文件的 `turnBusy`(唯一入口 `beginTurnBusy` / `endTurnBusy`)、`localMessages`、发送队列、 - * 埋点句柄与分页 ref 只服务发送与展示;界面上的 - * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 + * 状态变量归属:reducer 的回合字段、待发消息投影、收口计数与 `history` / `live` 只由 + * `directThreadChat.ts` 写;本文件的 `commandInFlight`(唯一入口 `beginCommandInFlight` / + * `endCommandInFlight`,只覆盖 IPC 在飞)、`localMessages`、埋点句柄与分页 ref 只服务发送与展示; + * 界面上的「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,两态判据与真值表在 * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ @@ -171,13 +167,10 @@ export function useDirectProjectChatController({ * 否则两次并发导入会拿着同一份草稿附件数各自放行,插进正文的芯片就超过上限。 */ const attachmentsPendingCountRef = useRef(0); - const [queuedTurns, setQueuedTurns] = useState([]); - const queuedTurnsRef = useRef([]); - queuedTurnsRef.current = queuedTurns; const [composerNotice, setComposerNotice] = useState(''); const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); - const [turnBusy, setTurnBusy] = useState(false); + const [commandInFlight, setCommandInFlight] = useState(false); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -189,113 +182,76 @@ export function useDirectProjectChatController({ const currentTurnRunning = directThread.turnRunning; const currentTurnStartedAt = directThread.turnStartedAt; const completedTurnCount = directThread.completedTurnCount; + /** + * 本轮开口条目的 canonical 身份:收口之后仍留着,用来把"刚结束的是哪一轮"讲清楚(结算埋点读它)。 + */ + const currentTurnUserItemId = directThread.state.turnUserItemId; + /** + * 待发消息:**投影自事件流**,界面不持有第二份队列。入队、取消、放行都只让这条列表跟着变。 + */ + const pendingTurns = directThread.state.pendingTurns; const [historyHasMore, setHistoryHasMore] = useState(false); const historyOldestItemIdRef = useRef(null); const historyLoadingRef = useRef(false); - const queueSequenceRef = useRef(0); const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; const directTurnRunningRef = useRef(currentTurnRunning); directTurnRunningRef.current = currentTurnRunning; - const turnBusyRef = useRef(turnBusy); - turnBusyRef.current = turnBusy; + const commandInFlightRef = useRef(commandInFlight); + commandInFlightRef.current = commandInFlight; /** 已经处理过的收口回合数:与 reducer 的计数比较,识别"又有回合结束了"。 */ const handledCompletedTurnCountRef = useRef(0); /** - * 起这一轮时的收口计数:宿主没给开始事件时只能靠"计数变过"认领这一轮(见下面的 effect)—— - * 一轮可能在同一次 consume 里开始并结束,那时 `turnRunning` 的上升沿永远不会被观察到。 + * 每个已经交给宿主的回合的埋点句柄,按 `clientTurnId` 存。 + * + * 必须按身份存:队列在宿主侧,同一时刻可能有好几条待发消息(各自带着自己的 attempt id), + * 而成绩是**各自那一轮**收尾时在宿主侧入账的——单个槽位只会结算到最后一个,前面的全部静默丢掉。 + * 句柄活到本轮终态(见下面结算的那个 effect)。 */ - const busyBaselineTurnCountRef = useRef(0); - /** 最近一次渲染时的收口计数:权限确认后的重跑是异步续跑,闭包里的值可能过期。 */ - const completedTurnCountRef = useRef(completedTurnCount); - completedTurnCountRef.current = completedTurnCount; - /** 有回合结束了、但还不能出队(本地忙态还没放掉)时挂起,等忙态放掉再出队。 */ - const completionPendingRef = useRef(false); - /** - * 本轮的埋点句柄。它在命令返回之后仍然要活着:成绩是**回合末**才在宿主侧入账的, - * 入队返回时结算只会静默丢掉这一次埋点(见 `settlePendingRunAnalytics`)。 - */ - const pendingRunAnalyticsRef = useRef<{ - clientTurnId: string; - runAnalytics: ReturnType; - } | null>(null); + const pendingRunAnalyticsRef = useRef( + new Map>(), + ); useEffect(() => { setAttachmentNotice(''); setComposerNotice(''); setStatusNotice(''); - setQueuedTurns([]); - queuedTurnsRef.current = []; setLocalMessages([]); - busyBaselineTurnCountRef.current = 0; setHistoryHasMore(false); historyOldestItemIdRef.current = null; handledCompletedTurnCountRef.current = 0; - completionPendingRef.current = false; - pendingRunAnalyticsRef.current = null; + pendingRunAnalyticsRef.current.clear(); }, [projectPath]); useEffect(() => { - if (!enabled || (!turnBusy && !currentTurnRunning)) return; + if (!enabled || (!commandInFlight && !currentTurnRunning)) return; const timer = window.setInterval(() => { void requestPlatformSessionRefresh().catch(() => { // 回合本身会用原始鉴权错误收尾;保活不能把它替换成无关的后台错误。 }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [enabled, turnBusy, currentTurnRunning]); + }, [enabled, commandInFlight, currentTurnRunning]); /** - * 宿主认领了这一轮:本地忙态交给原生真相。 - * - * 判据是**事件流里的回合边界**而不是命令的返回值:命令先返回、开始事件后到,中间那一段必须仍算 - * "命令在飞"——提前放掉,composer 会在两个回合之间开出一个能并发发送的空窗。 - * - * 两条判据任一条成立都算认领(后一条是兜底):开始事件已经落进 reducer(`turnRunning`), - * 或收口计数变过(这一轮在同一次 consume 里开始又结束,`turnRunning` 的上升沿观察不到)。 - */ - useEffect(() => { - if (!turnBusyRef.current) return; - if ( - currentTurnRunning || - completedTurnCount > busyBaselineTurnCountRef.current - ) { - endTurnBusy(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [turnBusy, currentTurnRunning, completedTurnCount]); - - /** - * 回合终态是队列放行与埋点结算的唯一出口(入队失败走命令那条路,见 `startTurn` 的收尾)。 + * 回合终态是**埋点结算**的唯一出口(入队失败走命令那条路,见 `runTurn` 的清理)。 * * 判据用 reducer 的**单调计数**而不是 `turnRunning` 的下降沿:一轮可能在同一次 consume 里 - * 开始并结束,那时下降沿永远不会出现,队列就永久卡住了。 + * 开始并结束,那时下降沿永远不会出现,这一轮的埋点就永远没人结算。 * - * TODO(发送队列):这条队列整体挪到宿主的待发消息队列(放行由 Thread Manager 自己驱动)。 + * 队列**不在这里推进**:待发消息归宿主,下一条什么时候走由 Thread Manager 在占用释放时自己踢。 */ useEffect(() => { if (!enabled) { handledCompletedTurnCountRef.current = completedTurnCount; - completionPendingRef.current = false; return; } - if (completedTurnCount !== handledCompletedTurnCountRef.current) { - handledCompletedTurnCountRef.current = completedTurnCount; - // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 - settlePendingRunAnalytics(); - completionPendingRef.current = true; - } - if ( - completionPendingRef.current && - !turnBusyRef.current && - !directTurnRunningRef.current - ) { - completionPendingRef.current = false; - dispatchNextQueuedTurn(); - } - // 出队只由"回合收口次数 + 本地忙态"驱动,队列本身不是依赖。 + if (completedTurnCount === handledCompletedTurnCountRef.current) return; + handledCompletedTurnCountRef.current = completedTurnCount; + // 先结算埋点:宿主此刻已经把这一轮的成绩写进候选,再晚也还是同一轮。 + settlePendingRunAnalytics(currentTurnUserItemId); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, completedTurnCount, turnBusy, currentTurnRunning]); + }, [enabled, completedTurnCount, currentTurnUserItemId]); useEffect(() => { if (!enabled || !projectPath) return; @@ -318,78 +274,83 @@ export function useDirectProjectChatController({ }, [enabled, projectPath]); /** - * 「本地命令在飞」的唯一起止点:按下发送时置上,宿主放行这一轮(`turn.started` 落进 reducer) - * 或这一轮明确没成立时放掉。 + * 「IPC 在飞」的唯一起止点:按下发送时置上,这一次 `invoke` 落地(成功或失败)时放掉。 * - * 它与原生忙态是两件事,所以**不在这里**按回合身份收口:入队化之后命令只等到入队就返回, - * 「宿主认领了吗」由 reducer 的 `turnRunning` 回答(`displayBusy` 是两者的并集)。 + * 它只盖命令这一段:宿主认领这一轮之后忙态由原生真相与待发消息条数回答(`displayBusy` 是三者 + * 的并集,见 `useDirectProjectTurnStatus`)。 */ - function beginTurnBusy() { - turnBusyRef.current = true; - setTurnBusy(true); - busyBaselineTurnCountRef.current = completedTurnCountRef.current; + function beginCommandInFlight() { + commandInFlightRef.current = true; + setCommandInFlight(true); } - function endTurnBusy() { - turnBusyRef.current = false; - setTurnBusy(false); + function endCommandInFlight() { + commandInFlightRef.current = false; + setCommandInFlight(false); } /** * 结算本轮埋点。只在**回合终态**调用:成绩是回合末才在宿主侧入账的,入队返回时就结算 * 会变成一次空操作(宿主找不到候选,静默丢弃)。入队失败那一轮没有候选,句柄由 `runTurn` 自己清掉。 + * + * 认领靠身份:`userItemId` 是本轮开口条目的 canonical id,与 `clientTurnId` 一一对应 + * (`directCodexConversationMessageId`)。身份缺失(旧事件 / 没有开口条目)时退一步结算**最早**的 + * 那一条——放行严格按队首顺序,收口顺序就是入队顺序,这一层没有歧义。 */ - function settlePendingRunAnalytics() { + function settlePendingRunAnalytics(turnUserItemId: string) { const pending = pendingRunAnalyticsRef.current; - pendingRunAnalyticsRef.current = null; - pending?.runAnalytics.settle(); + if (pending.size === 0) return; + const matched = [...pending.keys()].find( + (clientTurnId) => + directCodexConversationMessageId(clientTurnId, 'user') === + turnUserItemId, + ); + const key = matched ?? pending.keys().next().value; + if (key === undefined) return; + const handle = pending.get(key); + pending.delete(key); + handle?.settle(); } function appendLocalMessage(message: ChatMessage) { setLocalMessages((current) => [...current, message]); } - function dispatchNextQueuedTurn() { - if (turnBusyRef.current || directTurnRunningRef.current) return; - const { next, rest } = dequeueChatTurn(queuedTurnsRef.current); - if (!next) return; - completionPendingRef.current = false; - queuedTurnsRef.current = rest; - setQueuedTurns(rest); - if (rest.length === 0) setComposerNotice(''); - startTurn({ - clientTurnId: next.clientTurnId, - userItem: next.userItem, - }); - } - - function enqueueTurn(input: { - clientTurnId: string; - userItem: DirectProjectTurnInput['userItem']; - }) { - if (isChatTurnQueueFull(queuedTurnsRef.current)) { - setComposerNotice(chatQueueFullNotice()); - return false; + /** + * 取消一条**还没放行**的待发消息:由宿主在临界区里判断"这条还在不在队里",界面只把结果说给用户。 + * + * chip 的消失**不靠这里**:宿主真的移除了就会发 `queue.removed{cancelled}`,界面按事件投影撤掉它。 + * 所以这里不改本地任何队列状态——它不是事实源,改了就多出第二份。 + */ + async function cancelPendingTurn(clientTurnId: string) { + const nextProjectPath = projectPath; + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { + setComposerNotice('需要在 Tauri App 内运行,才能取消待发消息'); + return; + } + try { + const outcome = await invoke( + 'remove_direct_project_pending_turn', + { projectPath: nextProjectPath, clientTurnId }, + ); + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + outcome === 'notFound' + ? '这条待发消息已经不在队列里' + : // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 + outcome === 'alreadyDispatched' + ? '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」' + : '', + ); + } catch (error) { + if (projectPathRef.current !== nextProjectPath) return; + setComposerNotice( + `取消待发消息失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); } - queueSequenceRef.current += 1; - const turn = createQueuedChatTurn({ - id: `queued-chat-turn-${Date.now()}-${queueSequenceRef.current}`, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - createdAt: Date.now(), - }); - const next = enqueueChatTurn(queuedTurnsRef.current, turn); - queuedTurnsRef.current = next; - setQueuedTurns(next); - setComposerNotice('已加入发送队列,当前回合结束后自动发送'); - return true; - } - - function cancelQueuedTurn(id: string) { - const next = removeQueuedChatTurn(queuedTurnsRef.current, id); - queuedTurnsRef.current = next; - setQueuedTurns(next); - if (next.length === 0) setComposerNotice(''); } /** @@ -480,37 +441,23 @@ export function useDirectProjectChatController({ setComposerNotice(''); } - function submit(content: DirectCodexUserContentPart[]): boolean { + /** + * 提交草稿:**只有一条路**——交给宿主入队。 + * + * 本文件不再有"回合运行中就先排队"的分支:队列在宿主侧,忙不忙都由宿主自己判(队满、重复入队 + * 也由它回答)。返回值就是"草稿能不能清":入队失败里用户自己能改的那些返回 `false`,草稿留下。 + */ + async function submit( + content: DirectCodexUserContentPart[], + ): Promise { if (!hasMeaningfulDirectCodexContent(content)) return false; - if (turnBusyRef.current || currentTurnRunning) { - const clientTurnId = createDirectProjectTurnId(); - if ( - enqueueTurn({ - clientTurnId, - userItem: directCodexUserItemFromContent( - content, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - }) - ) { - // 只清草稿:enqueueTurn 刚写入的「已加入发送队列」提示必须留给用户看到。 - clearDraftInput(); - return true; - } - return false; - } const clientTurnId = createDirectProjectTurnId(); const userItem = directCodexUserItemFromContent( content, directCodexConversationMessageId(clientTurnId, 'user'), ); - const turn: DirectProjectTurnInput = { - clientTurnId, - userItem, - }; clearPendingInput(); - startTurn(turn); - return true; + return startTurn({ clientTurnId, userItem }); } /** @@ -519,7 +466,7 @@ export function useDirectProjectChatController({ * 这里**不往聊天里写用户消息**:这一轮的用户气泡只来自宿主条目,所以"入队窗口期聊天区没有这一 * 轮"是正常现象(忙态与状态行负责告知)。权限确认后重跑的是同一份输入,只跳过权限检查。 */ - function startTurn(input: DirectProjectTurnInput) { + async function startTurn(input: DirectProjectTurnInput): Promise { const nextProjectPath = projectPath; if (!nextProjectPath || !projectId) { const message = resolveTauriInvoke() @@ -537,68 +484,52 @@ export function useDirectProjectChatController({ ), updatedAt: Date.now(), }); - return; + return true; } - beginTurnBusy(); - void (async () => { - let invoked = false; - // 命令是否接了单。只有它为真时,这一轮的收尾才交给宿主的事件。 - let turnAccepted = false; - // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnBusy() 之后: - // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 - let queueAdvance = false; - try { - onRuntimeError(''); - if (!input.directPolicyChecked) { - const allowed = await ensureConversationWriteAllowed({ - projectPath: nextProjectPath, - onConfirmed: () => { - startTurn(directCodexPolicyRetryInput(input)); - }, - }); - if (!allowed) { - // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), - // 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会 - // 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。 - queueAdvance = true; - return; - } - } - const invoke = resolveTauriInvoke(); - if (!invoke) return; - invoked = true; - turnAccepted = await runTurn(invoke, nextProjectPath, input); - } catch (error) { - // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, - // 已经乐观追加的用户消息也没有任何解释。 - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - error instanceof Error ? error.message : String(error), - ); - } - } finally { - setTurnCancelling(false); - // 权限门没放行时本轮从未发出:不刷新清单,也不推进队列,等确认后重跑。 - if (invoked) { - await refreshDirectManifest(nextProjectPath); - } - // 收尾分两种:入队成立的整轮交给宿主的事件(`turn.started` 时退场、`turn.completed` - // 时出队与结算,见上面两个 effect);入队失败的那些路径没有任何事件会来,只能在这里收口。 - if (!turnAccepted) { - // 忙态必须早于出队放掉:出队会同步开始下一轮并设上它自己的忙态,清在它后面就等于 - // 把下一轮的忙态抹掉(composer 会以为可以并发发送)。 - endTurnBusy(); - // 出队条件:权限被拒的一轮从未发出,不受项目切换影响,照旧出队;命令真发出过又返回 - // 入队失败时要求项目没被换掉;没有 invoke(非 Tauri 环境)时不出队,避免空转。 - if ( - queueAdvance || - (invoked && projectPathRef.current === nextProjectPath) - ) { - dispatchNextQueuedTurn(); - } + beginCommandInFlight(); + /** + * 草稿能不能清:入队成立、交给权限确认流程、认不出的宿主 / 环境失败都算已交出; + * 只有"用户自己就能改"的入队失败(宿主已经给了同级提示)才留着草稿,别让人重打一遍。 + */ + let draftSafe = false; + let invoked = false; + try { + onRuntimeError(''); + if (!input.directPolicyChecked) { + const allowed = await ensureConversationWriteAllowed({ + projectPath: nextProjectPath, + onConfirmed: () => { + void startTurn(directCodexPolicyRetryInput(input)); + }, + }); + if (!allowed) { + // 写权限门返回 false 有两种:策略拒绝(这一轮不会重跑)与交给确认流程(确认后重跑同一份 + // 内容)。两种都不该再让用户按一次发送——内容已经在重跑的入参里(或者用户已经被明确拒绝), + // 所以草稿照清,与入队化之前一致。 + return true; } } - })(); + const invoke = resolveTauriInvoke(); + if (!invoke) return true; + invoked = true; + draftSafe = await runTurn(invoke, nextProjectPath, input); + } catch (error) { + // 写权限门等前置步骤抛出时不能只留一个未处理的 rejection:回合会静默失败, + // 这一轮的任何说明都没有出口。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(error instanceof Error ? error.message : String(error)); + } + draftSafe = true; + } finally { + setTurnCancelling(false); + // 命令已经落地,IPC 在飞这一段结束;这一轮还在不在跑由事件流回答。 + endCommandInFlight(); + // 权限门没放行时本轮从未发出:不刷新清单,等确认后重跑。 + if (invoked) { + await refreshDirectManifest(nextProjectPath); + } + } + return draftSafe; } async function runTurn( @@ -607,18 +538,16 @@ export function useDirectProjectChatController({ input: DirectProjectTurnInput, ): Promise { // 命令返回 `Ok` 只说明**入队成立**:整轮怎么收场只由 `turn.completed` 事件回答。 - // 所以这里的返回值只服务队列放行——"这一轮有没有真的开始"。 - let turnAccepted = false; + // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 + let draftSafe = false; try { const runAnalytics = beginDirectRunAnalytics( invoke, currentPlatformSessionGeneration, ); // 埋点句柄必须活过命令返回:成绩是回合末才入账的,settle 只能在回合终态发生。 - pendingRunAnalyticsRef.current = { - clientTurnId: input.clientTurnId, - runAnalytics, - }; + // 按 `clientTurnId` 存:队列在宿主侧,同一时刻可能有好几条待发消息各自带着自己的身份。 + pendingRunAnalyticsRef.current.set(input.clientTurnId, runAnalytics); await invoke('enqueue_direct_codex_turn', { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, @@ -626,7 +555,7 @@ export function useDirectProjectChatController({ analyticsAttemptId: runAnalytics.nextAttempt(), ...(input.creationType ? { creationType: input.creationType } : {}), }); - turnAccepted = true; + draftSafe = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 @@ -635,17 +564,15 @@ export function useDirectProjectChatController({ // 只有结构化入队失败能证明"这一轮没成立"(入队失败不产生回合事件、也就不会有埋点候选), // 句柄才只清不发;非结构化错误(IPC 失败、命令 panic)可能发生在放行之后,那时必须留着句柄等 // `turn.completed` 来结算——提前清掉会让宿主侧这一轮的候选永远没有人结算。 - if ( - rejection && - pendingRunAnalyticsRef.current?.clientTurnId === input.clientTurnId - ) { - pendingRunAnalyticsRef.current = null; + if (rejection) { + pendingRunAnalyticsRef.current.delete(input.clientTurnId); } if (rejection) { const notice = directTurnEnqueueFailureNotice(rejection); if (notice) { // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 - // 也不上报(用户自己就能改,上报只会变成噪声)。 + // 也不上报(用户自己就能改,上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条,界面上 + // 那条同级提示说的是"为什么",用户改一改就能直接再按发送。 if (projectPathRef.current === nextProjectPath) { onRuntimeError(''); appendLocalMessage({ @@ -661,7 +588,7 @@ export function useDirectProjectChatController({ return false; } } - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; @@ -673,7 +600,10 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return false; + if (projectPathRef.current !== nextProjectPath) return true; + // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 + // 用户以为没发出去、再按一次。 + draftSafe = true; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 @@ -693,7 +623,7 @@ export function useDirectProjectChatController({ // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 onRuntimeError(visibleMessage); } - return turnAccepted; + return draftSafe; } async function refreshDirectManifest(nextProjectPath: string) { @@ -710,7 +640,7 @@ export function useDirectProjectChatController({ if ( !invoke || !projectPath || - (!turnBusyRef.current && !currentTurnRunning) + (!commandInFlightRef.current && !currentTurnRunning) ) { onRuntimeError('当前没有正在运行的回合,无法终止。'); return; @@ -728,7 +658,7 @@ export function useDirectProjectChatController({ if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 // 已经把终态写进事件流,界面等那条 `turn.completed` 自己落到 reducer 上。 - endTurnBusy(); + endCommandInFlight(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -850,7 +780,7 @@ export function useDirectProjectChatController({ appendLocalMessage, attachmentNotice, attachmentsImporting, - cancelQueuedTurn, + cancelPendingTurn, cancelTurn, composerNotice, directEntries, @@ -858,12 +788,12 @@ export function useDirectProjectChatController({ directTurnStartedAt: currentTurnStartedAt, historyHasMore, loadEarlierHistory, + commandInFlight, localMessages, - queuedTurns, + pendingTurns, startInitialTurn: startTurn, statusNotice, submit, - turnBusy, turnCancelling, uploadFiles, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index b884211e9..86632de3e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; +import type { DirectPendingTurn } from '../conversation/directPendingTurns'; import type { DirectChatTurn, DirectChatTurnState, @@ -8,19 +9,21 @@ import type { /** * DirectProject「这一轮在跑吗」的唯一派生入口。 * - * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、 + * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`(今 `commandInFlight`)、 * 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义 * 一次讲清楚,组件只读这一个对象: * * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` * 已到、`turn.completed` 未到)。 - * - `commandInFlight`:**本地真相**。本次会话是否有一条本地在飞的回合(写权限门 → invoke → - * 宿主放行这一轮);它从按下发送那一刻就为真,与原生是否已经开始无关。入队化之后命令只 - * 等到入队就返回,所以它不等于"命令还没返回"——它活到宿主那一轮的开始事件被观察到为止。 - * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,就是两者的并集: + * - `commandInFlight`:**本地真相**,而且只覆盖 **IPC 在飞**这一段:从按下发送到入队命令返回。 + * 入队化之后命令只等到入队就返回(放行由宿主自己完成),所以它不再是"这一轮还没被认领", + * 只是"这一次调用还没落地"。 + * - `pendingCount`:**队列真相**。宿主事件流折出来的待发消息条数(`queue.enqueued` / + * `queue.removed` 两条事件的投影);界面不持有第二份队列。 + * - `displayBusy`:header / composer / 「陶泥儿正在处理」卡片该读的忙态,是三者的并集: * 只要有一条成立就不能再接受新的发送。卡片读它而不是 `nativeRunning`:`turn.started` - * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」 - * 的交代(2026-09-24 口令)。 + * 要等宿主应答返回才发出,只认原生真相会让模型首 token 之前那十来秒没有任何「正在处理」的 + * 交代(2026-09-24 口令)。 * - `latestTurnState`:最新一轮在界面上的两态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 @@ -31,41 +34,54 @@ import type { export type DirectProjectTurnStatus = { nativeRunning: boolean; commandInFlight: boolean; + pendingCount: number; displayBusy: boolean; latestTurnState: DirectChatTurnState | null; }; export function deriveDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { const nativeRunning = Boolean(turnRunning); - const commandInFlight = Boolean(turnBusy); + const inFlight = Boolean(commandInFlight); + const pendingCount = pendingTurns.length; const latest = turns.length > 0 ? turns[turns.length - 1] : null; return { nativeRunning, - commandInFlight, - displayBusy: nativeRunning || commandInFlight, + commandInFlight: inFlight, + pendingCount, + displayBusy: nativeRunning || inFlight || pendingCount > 0, latestTurnState: latest ? latest.state : null, }; } export function useDirectProjectTurnStatus({ turnRunning, - turnBusy, + commandInFlight, + pendingTurns, turns, }: { turnRunning: boolean; - turnBusy: boolean; + commandInFlight: boolean; + pendingTurns: readonly DirectPendingTurn[]; turns: readonly DirectChatTurn[]; }): DirectProjectTurnStatus { return useMemo( - () => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }), - [turnRunning, turnBusy, turns], + () => + deriveDirectProjectTurnStatus({ + turnRunning, + commandInFlight, + pendingTurns, + turns, + }), + [turnRunning, commandInFlight, pendingTurns, turns], ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts new file mode 100644 index 000000000..72fa4dfad --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts @@ -0,0 +1,49 @@ +/** + * 待发消息队列的**投影**。 + * + * 队列的事实源只有一个:运行态事件流(`queue.enqueued` / `queue.removed`),和回合边界一样。 + * 这里只有"把两条事件折成一份有序列表"的纯逻辑,不持有任何自己的状态、不做上限判断、不排期—— + * 队列归宿主(`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`), + * 界面只把宿主给的形状折成 chip。 + * + * 列表顺序就是事件的到达顺序(FIFO,宿主侧的认领也认队首),所以这里没有排序、没有身份表。 + */ +import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; + +/** 一条还没放行的待发消息。 */ +export type DirectPendingTurn = { + /** 这条待发消息的回合身份;放行之后同一轮的 `turn.started` / `turn.completed` 用它。 */ + clientTurnId: string; + /** canonical 用户条目:chip 文案由它派生(与聊天正文同一个派生)。 */ + userItem: DirectCodexUserItem; + /** 入队那一刻的宿主毫秒钟。 */ + at: number; +}; + +/** + * 队尾追加。同一个 `clientTurnId` 已经在队里时**原样返回入参**(引用相同),让调用方能把 + * "没有变化"直接折回原状态,不制造一次无意义的重渲染。 + */ +export function enqueuePendingTurn( + pending: readonly DirectPendingTurn[], + turn: DirectPendingTurn, +): readonly DirectPendingTurn[] { + if (pending.some((item) => item.clientTurnId === turn.clientTurnId)) { + return pending; + } + return [...pending, turn]; +} + +/** + * 按身份移除,取消与放行共用(原因只决定界面话术,不改这里的形状)。 + * + * 身份不在队里时同样原样返回:放行事件与 `turn.started` 同批到达,重复处理不能把后来的 + * 条目一起带走。 + */ +export function removePendingTurn( + pending: readonly DirectPendingTurn[], + clientTurnId: string, +): readonly DirectPendingTurn[] { + const next = pending.filter((item) => item.clientTurnId !== clientTurnId); + return next.length === pending.length ? pending : next; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 85d58327e..12a1d728b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -1,10 +1,11 @@ /** * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + - * 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑, - * `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期 - * 事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"与"还没放行的待发消息"。顺序 = 历史 + * 文件顺序 + 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个 + * 回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命 + * 周期事件自带的 canonical user identity(`userItemId`)做展示边界关联,不新建回合注册表。 + * 待发消息同样是事件折出来的列表(`queue.enqueued` / `queue.removed`),没有第二份队列状态。 * * 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了 * `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源 @@ -17,6 +18,11 @@ import type { DirectThreadEvent } from '../generated/DirectThreadEvent'; import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from './directPendingTurns'; import { projectDirectThreadItem } from './directThreadItemProjection'; import { directTurnFailureItemId, @@ -70,7 +76,7 @@ export type DirectThreadChatState = { * 的先后决定。 * * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段窗口里它为假, - * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 controller 的 `turnBusy` 出。 + * 那时聊天区里没有这一轮的任何条目(本地不再造乐观气泡),忙态由 `useDirectProjectTurnStatus` 的 `displayBusy` 出。 * 界面侧的两态与判据见 `directTurnPresentation.ts` 的 `DirectChatTurnState`。 */ turnRunning: boolean; @@ -88,12 +94,22 @@ export type DirectThreadChatState = { /** * 已经收口的回合数(单调递增,项目切换时随整份状态重置)。 * - * 它是"回合完成"这个事实**唯一的计数**,给上层放行发送队列与结算埋点用。为什么要计数 - * 而不是看 `turnRunning` 的下降沿:一轮可能在**同一次 consume** 里开始并结束(放行后 - * 立刻失败),那时 `turnRunning` 从头到尾没有被观察到真,下降沿永远不会来。计数是状态, - * 批量到达也一样看得见。 + * 它是"回合完成"这个事实**唯一的计数**,只给上层结算埋点用(放行不归它管——队列在宿主侧, + * 由 Thread Manager 自己认领队首)。为什么要计数而不是看 `turnRunning` 的下降沿:一轮可能在 + * **同一次 consume** 里开始并结束(放行后立刻失败),那时 `turnRunning` 从头到尾没有被观察到 + * 真,下降沿永远不会来。计数是状态,批量到达也一样看得见。 */ completedTurnCount: number; + /** + * 还没放行的待发消息,保持入队顺序(FIFO)。 + * + * 它**不是**第二份队列状态:入队与移除都由 `queue.enqueued` / `queue.removed` 两条运行态事件 + * 折出来,与回合边界同一个事实源。因此新订阅者拿到 bootstrap 的 live-set 就等于拿到了此刻的 + * 队列(在队条目不可回收),另一个窗口、离开工作台再回来看到的都是同一份。 + * + * 上限、幂等、认领顺序全部在宿主侧,这里不判、不排期、不自己放行。 + */ + pendingTurns: readonly DirectPendingTurn[]; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ @@ -107,6 +123,7 @@ export function emptyDirectThreadChatState(): DirectThreadChatState { turnEndedAt: 0, turnUserItemId: '', completedTurnCount: 0, + pendingTurns: [], history: [], live: [], }; @@ -427,6 +444,29 @@ export function reduceDirectThreadEvent( } return upsertLiveEntry(state, entry); } + case 'queue.enqueued': { + // 在队条目的身份就是 `clientTurnId`:宿主对同一条消息的重复入队返回同一次成功,这里也按同一 + // 身份折回原状态,不让同一句话在 chip 里出现两次。 + const pendingTurns = enqueuePendingTurn(state.pendingTurns, { + clientTurnId: event.clientTurnId, + userItem: event.userItem, + at: event.at, + }); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } + case 'queue.removed': { + // 取消与放行都只是"这条不再是待发消息":放行那一批里同时有 `turn.started`,用户气泡由宿主 + // 条目给出,chip 消失与气泡出现在同一批 consume 里,中间没有空窗。 + const pendingTurns = removePendingTurn( + state.pendingTurns, + event.clientTurnId, + ); + return pendingTurns === state.pendingTurns + ? state + : { ...state, pendingTurns }; + } case 'request': // 审批 / 提问只影响面板交互,不并入聊天条目。 return state; diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 51c3db79e..132940ace 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,14 +1,5 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import * as platformSession from '../../src/services/platformSession'; -import { - chatQueueFullNotice, - createQueuedChatTurn, - dequeueChatTurn, - enqueueChatTurn, - isChatTurnQueueFull, - queuedChatTurnLabel, - removeQueuedChatTurn, -} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts'; import { appendDictationText, resolveSpeechRecognitionCtor, @@ -18,11 +9,16 @@ import { VOICE_INPUT_UNSUPPORTED_MESSAGE, } from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts'; import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx'; +import { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts'; +import { + type DirectPendingTurn, + enqueuePendingTurn, + removePendingTurn, +} from '../../src/view/project-development/chat/conversation/directPendingTurns.ts'; import { act, createGameCreationAppManifest, createProjectChatRuntimeHarness, - emptyProjectPolicy, expect, fireEvent, it, @@ -214,38 +210,111 @@ function installFakeSpeechRecognition(): { }; } -/** 排队回合只持 canonical user item;展示文案由它派生。 */ -function queuedTurn( - id: string, +/** + * 一条待发消息的投影形状:事件带 canonical user item,chip 文案由它派生。 + * + * 身份换算与生产同源(`direct-codex:{clientTurnId}:{role}`),不另造一套。 + */ +function pendingTurn( clientTurnId: string, text: string, - createdAt: number, -) { - return createQueuedChatTurn({ - id, + at = 1, +): DirectPendingTurn { + return { clientTurnId, userItem: directCodexUserItemFromContent( [{ type: 'input_text', text }], - `${clientTurnId}:user`, + `direct-codex:${clientTurnId}:user`, ), - createdAt, - }); + at, + }; +} + +/** + * 宿主的队列动作在测试里的替身:入队、取消、回合收尾后放行队首。 + * + * 队列归宿主之后界面看到的每一步都来自事件——入队发 `queue.enqueued`,线程空闲时在同一次认领里 + * 追加 `queue.removed{dispatched}` 与 `turn.started`,取消发 `queue.removed{cancelled}`。 + * 这里只做 Thread Manager 会做的那几件事,顺序就是入队顺序(认领永远认队首)。 + */ +function hostQueue( + harness: ReturnType, + /** 打开面板时线程上是否已经有一条没收口的回合(恢复出来的那一轮)。 */ + { running: alreadyRunning = false }: { running?: boolean } = {}, +) { + const queued: Array<{ clientTurnId: string; userItem: unknown }> = []; + let running = alreadyRunning; + const dispatchHeadIfIdle = () => { + if (running) return; + const head = queued.shift(); + if (!head) return; + running = true; + harness.emitDirectThreadEvents( + { + type: 'queue.removed', + clientTurnId: head.clientTurnId, + reason: 'dispatched', + at: 2, + }, + { + type: 'turn.started', + at: 2, + userItemId: `direct-codex:${head.clientTurnId}:user`, + }, + ); + }; + return { + /** 入队命令的宿主实现:入队 → 线程空闲就顺手放行。 */ + enqueue(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + queued.push({ clientTurnId, userItem: args?.userItem }); + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId, + userItem: args?.userItem, + at: 1, + }); + dispatchHeadIfIdle(); + return null; + }, + /** 取消命令的宿主实现:真的在队里才移除,并广播原因。 */ + cancel(args?: Record) { + const clientTurnId = String(args?.clientTurnId ?? ''); + const index = queued.findIndex( + (item) => item.clientTurnId === clientTurnId, + ); + if (index < 0) return running ? 'alreadyDispatched' : 'notFound'; + queued.splice(index, 1); + harness.emitDirectThreadEvents({ + type: 'queue.removed', + clientTurnId, + reason: 'cancelled', + at: 3, + }); + return 'removed'; + }, + /** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */ + complete(status = 'completed') { + running = false; + harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 }); + dispatchHeadIfIdle(); + }, + }; } export function registerChatComposerControlTests() { - it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { - const turn = createQueuedChatTurn({ - id: 'turn-ref', + it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { + const turn: DirectPendingTurn = { clientTurnId: 'client-ref', userItem: directCodexUserItemFromContent( [ { type: 'input_text', text: '用这张图改一下' }, { type: 'agc_resource_reference', resourceId: 'asset:hero' }, ], - 'client-ref:user', + 'direct-codex:client-ref:user', ), - createdAt: 1, - }); + at: 1, + }; const manifestAssets = [ { id: 'asset:hero', @@ -258,57 +327,77 @@ export function registerChatComposerControlTests() { // chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`; // 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。 - expect(queuedChatTurnLabel(turn, manifestAssets)).toBe( + expect(pendingTurnChipLabel(turn, manifestAssets)).toBe( '用这张图改一下 @hero', ); + // 空内容也给一行可显示的字,不让 chip 变成一条空白带。 + expect(pendingTurnChipLabel(pendingTurn('client-empty', ''), [])).toBe( + '未命名消息', + ); }); - it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { - const first = queuedTurn('turn-1', 'client-1', '第一条', 1); - const second = queuedTurn('turn-2', 'client-2', '第二条', 2); - const third = queuedTurn('turn-3', 'client-3', '第三条', 3); + it('folds the pending queue from events: FIFO append, identity removal, idempotent enqueue', () => { + let pending: readonly DirectPendingTurn[] = []; + pending = enqueuePendingTurn(pending, pendingTurn('client-1', '第一条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-2', '第二条')); + pending = enqueuePendingTurn(pending, pendingTurn('client-3', '第三条')); - let queue = enqueueChatTurn([], first); - queue = enqueueChatTurn(queue, second); - queue = enqueueChatTurn(queue, third); - // 同一条消息重复入队不得变成两次发送。 - expect(enqueueChatTurn(queue, third)).toHaveLength(3); - - // FIFO:先入先出,不丢、不乱序。 - const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.clientTurnId).toBe('client-1'); - expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe( + // 顺序就是事件顺序(宿主认领队首,所以界面不许自己排)。 + expect(pending.map((turn) => pendingTurnChipLabel(turn, []))).toEqual([ '第一条', - ); - expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([ '第二条', '第三条', ]); - // 单条取消只移除那一条,顺序不变。 - expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => - queuedChatTurnLabel(turn, []), - ), - ).toEqual(['第三条']); - expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); + // 同一条消息重复入队不产生第二个 chip:宿主的入队是幂等的,投影这一层也按身份折回原状态 + // (引用不变,调用方能直接跳过重渲染)。 + expect(enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'))).toBe( + pending, + ); - // 空队列出队不报错、也不产生"幽灵消息"。 - expect(dequeueChatTurn([]).next).toBeNull(); + // 取消与放行共用同一条移除:按身份删,其余顺序不变。 + expect( + removePendingTurn(pending, 'client-2').map((turn) => + pendingTurnChipLabel(turn, []), + ), + ).toEqual(['第一条', '第三条']); + // 身份不在队里时原样返回(放行事件与 `turn.started` 同批到达、或事件被重放)。 + expect(removePendingTurn(pending, 'client-missing')).toBe(pending); }); - it('reports a readable reason instead of silently dropping a full queue', () => { - let queue: ReturnType[] = []; - for (let index = 0; index < 5; index += 1) { - queue = enqueueChatTurn( - queue, - queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), - ); - } - expect(isChatTurnQueueFull(queue)).toBe(true); - expect(chatQueueFullNotice()).toContain('队列已满'); - expect(chatQueueFullNotice()).toContain('5'); - expect(removeQueuedChatTurn(queue, 'turn-0')).toHaveLength(4); + it('keeps the draft and explains why when the host rejects the enqueue as full', async () => { + // 队列上限只在宿主一处(`MAX_PENDING_DIRECT_TURNS`):界面不再自己数一份、不再自己造一句 + // 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。 + const { invoke, surface } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: () => { + throw { + error: { type: 'queueFull', limit: 5 }, + message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '第六条消息'); + + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', + ), + ).not.toBeNull(); + }); + // 草稿没丢:这条消息还在输入盒里,用户改一改或等一会儿就能重发。 + expect(composer.textContent ?? '').toContain('第六条消息'); + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + // 入队没成立就是没成立:只发了一次命令,也不会有回合事件来结算埋点。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); it('degrades the voice input button with a readable hint when speech recognition is missing', async () => { @@ -478,14 +567,14 @@ export function registerChatComposerControlTests() { }); }); - it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { + it('enqueues messages sent while a turn runs, cancels one chip, and never sends the same message twice', async () => { + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface({ - // 命令只入队(放行由 Thread Manager 自己完成),随后立刻返回。 - enqueue_direct_codex_turn: () => { - harness.emitDirectThreadEvents({ type: 'turn.started' }); - return Promise.resolve(null); - }, + // 宿主替身:入队就发 `queue.enqueued`,线程空闲时同一次认领里放行队首。 + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), }); + host = hostQueue(harness); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '第一条消息'); await waitFor(() => { @@ -494,16 +583,14 @@ export function registerChatComposerControlTests() { expectDirectTurnWithText('第一条消息'), ); }); - // 回合运行中:发送钮位置变成终止钮。 + // 第一条被立刻放行:回合运行中,发送钮位置变成终止钮,队列里没有它(它不是待发消息)。 expect( await within(surface).findByRole('button', { name: '终止' }), ).not.toBeNull(); + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); await setComposerText(composer, '第二条消息'); submitComposerForm(composer); - await waitFor(() => { - expect(within(surface).getByText('第二条消息')).not.toBeNull(); - }); await setComposerText(composer, '第三条消息'); submitComposerForm(composer); @@ -520,42 +607,73 @@ export function registerChatComposerControlTests() { expect.stringContaining('第三条消息'), ]); - // 单条取消:只移除第二条,第三条保留。 + // 单条取消:命令交给宿主判,界面按 `queue.removed{cancelled}` 撤掉那一条,顺序不变。 fireEvent.click( - within(queue).getByRole('button', { name: '取消排队消息 第二条消息' }), + within(queue).getByRole('button', { name: '取消待发消息 第二条消息' }), ); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); + }); await waitFor(() => { expect(within(queue).getAllByRole('listitem')).toHaveLength(1); }); + expect(queue.textContent ?? '').toContain('第三条消息'); - // 队列放行听的是**回合终态**,不是命令返回:入队之后命令早就回来了。 + // 回合收尾 → 宿主放行剩下的那条:chip 消失与它自己的回合开始在同一批事件里。 act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('第三条消息'), - ); - }); - // 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。 - const sentTexts = invoke.mock.calls - .filter(([command]) => command === 'enqueue_direct_codex_turn') - .map(([, args]) => directTurnInputText(args)); - expect(sentTexts).toEqual(['第一条消息', '第三条消息']); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); + host?.complete(); }); await waitFor(() => { expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); }); + + // 关键不变式:排队的那条**没有**再走一次发送命令,入队就是一次性动作。 + expect( + invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => directTurnInputText(args)), + ).toEqual(['第一条消息', '第二条消息', '第三条消息']); + }); + + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { + // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 + const { invoke, surface, harness } = await openDirectCodexSurface({ + // 入队成立但并不放行(线程上已经有别的回合在跑):chip 留着,取消才有东西可取消。 + enqueue_direct_codex_turn: (args) => { + harness.emitDirectThreadEvents({ + type: 'queue.enqueued', + clientTurnId: String(args?.clientTurnId ?? ''), + userItem: args?.userItem, + at: 1, + }); + return null; + }, + remove_direct_project_pending_turn: () => 'alreadyDispatched', + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '还没跑的这条'); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('还没跑的这条')).not.toBeNull(); + + fireEvent.click( + within(queue).getByRole('button', { name: '取消待发消息 还没跑的这条' }), + ); + await waitFor(() => { + expect( + within(surface).getByText( + '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」', + ), + ).not.toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'remove_direct_project_pending_turn', + ), + ).toHaveLength(1); }); it('keeps the accept window silent in the chat and busy in the composer', async () => { @@ -748,76 +866,55 @@ export function registerChatComposerControlTests() { }); }); - it('keeps the next queued turn busy when the write gate refuses the running one', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; - const deferredPolicies: Array<(value: unknown) => void> = []; - let policyAllowsWrite = false; + it('does not fire a second enqueue while the first one is still in flight', async () => { + // 草稿要等命令的入队结果才知道能不能清,所以这一段提交是"在飞"的。这期间第二次提交必须被挡住: + // 同一份草稿并发出两条就是两条消息(宿主按 `clientTurnId` 幂等也救不了——第二次是新身份)。 + const pending: Array<{ resolve: (value: null) => void }> = []; const { invoke, surface } = await openDirectCodexSurface({ - read_project_permission_policy: () => { - if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy()); - return new Promise((resolve) => { - deferredPolicies.push(resolve); - }); - }, enqueue_direct_codex_turn: () => - new Promise((resolve) => { + new Promise((resolve) => { pending.push({ resolve }); }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); - await submitDirectTurn(surface, composer, '被拒的那条'); - - // 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。 - await setComposerText(composer, '后面那条'); - submitComposerForm(composer); - const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('后面那条')).not.toBeNull(); - - // 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑, - // 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。 - policyAllowsWrite = true; - await act(async () => { - for (const resolve of deferredPolicies.splice(0)) { - resolve({ - path: '.agent/policy.json', - policy: { - deniedCommands: [], - confirmCommands: ['conversation.write'], - }, - }); - } - }); - + await submitDirectTurn(surface, composer, '在飞的那条'); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('后面那条'), - ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); }); - // 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。 - expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - expect( - within(surface).getByRole('button', { name: '终止' }), - ).not.toBeNull(); + submitComposerForm(composer); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + + // 命令落地(入队成立):草稿清掉、IPC 在飞这一段结束,用户能接着发下一条。 await act(async () => { - pending[0]?.resolve('回复'); + pending[0]?.resolve(null); + }); + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); }); }); - it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { - const pending: Array<{ resolve: (value: string) => void }> = []; + it('queues the next message on the host while a turn is already running', async () => { + // 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。 + let host: ReturnType | null = null; const { invoke, surface, harness } = await openDirectCodexSurface( - { - enqueue_direct_codex_turn: () => - new Promise((resolve) => { - pending.push({ resolve }); - }), - }, + { enqueue_direct_codex_turn: (args) => host?.enqueue(args) }, (directHarness) => { directHarness.emitDirectThreadEvents({ type: 'turn.started' }); }, ); + // 线程上已经有回合在跑:入队成立但不放行,chip 就留着。 + host = hostQueue(harness, { running: true }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); expect( @@ -825,37 +922,35 @@ export function registerChatComposerControlTests() { ).not.toBeNull(); expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); - await setComposerText(composer, '恢复后排队的消息'); + // 回合运行中发送钮位置是终止钮,所以这里走表单提交(Enter 也是这条路)。 + await setComposerText(composer, '排在后面的消息'); submitComposerForm(composer); const queue = await within(surface).findByLabelText('待发送消息队列'); - expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'enqueue_direct_codex_turn', - ), - ).toHaveLength(0); - - act(() => { - harness.emitDirectThreadEvents({ - type: 'turn.completed', - status: 'completed', - }); - }); await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'enqueue_direct_codex_turn', - expectDirectTurnWithText('恢复后排队的消息'), - ); + expect(within(queue).getByText('排在后面的消息')).not.toBeNull(); }); + // 入队就是一次性动作:这条消息只有这一次命令,没有"先存本地、之后由这个窗口再发一次"。 expect( invoke.mock.calls.filter( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); - await act(async () => { - pending[0]?.resolve('排队回合回复'); + // 宿主在回合收尾后自己放行队首:chip 消失,那一轮的回合开始事件同批到达。 + act(() => { + host?.complete(); }); + await waitFor(() => { + expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); }); it('terminates the running turn and returns the composer to the idle state', async () => { diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts index 9c8e210b1..779b70314 100644 --- a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus'; +import type { DirectPendingTurn } from '../src/view/project-development/chat/conversation/directPendingTurns'; import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation'; const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ @@ -13,62 +14,91 @@ const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ endedAt: 0, }); +/** 待发消息:这里只关心条数,内容与 chip 文案无关。 */ +const pending = (clientTurnId: string): DirectPendingTurn => ({ + clientTurnId, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text: clientTurnId }], + }, + at: 1, +}); + describe('DirectProject 回合状态派生', () => { - it('displayBusy 是原生真相与本地命令在飞的并集', () => { + it('displayBusy 是原生真相、IPC 在飞与待发消息条数的并集', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], + turns: [], + }).displayBusy, + ).toBe(true); + // 队列非空也是忙:还有一条待发消息在宿主手里,这时不能假装空闲。 + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + commandInFlight: false, + pendingTurns: [pending('client-1')], turns: [], }).displayBusy, ).toBe(true); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).displayBusy, ).toBe(false); }); - it('两个来源各自独立暴露,不被并集吃掉', () => { + it('三个来源各自独立暴露,不被并集吃掉', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: true, - turnBusy: true, + commandInFlight: true, + pendingTurns: [pending('client-1'), pending('client-2')], turns: [], }); expect(status.nativeRunning).toBe(true); expect(status.commandInFlight).toBe(true); + expect(status.pendingCount).toBe(2); }); it('latestTurnState 取最新一轮的两态;没有回合时为 null', () => { expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [turn('u1', 'finished'), turn('u2', 'running')], }).latestTurnState, ).toBe('running'); expect( deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: false, + commandInFlight: false, + pendingTurns: [], turns: [], }).latestTurnState, ).toBeNull(); }); - it('本地命令在飞不等于原生在跑', () => { + it('IPC 在飞不等于原生在跑', () => { const status = deriveDirectProjectTurnStatus({ turnRunning: false, - turnBusy: true, + commandInFlight: true, + pendingTurns: [], turns: [turn('u1', 'finished')], }); expect(status.nativeRunning).toBe(false); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 4b385780e..ceb68ede4 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -284,6 +284,134 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); }); + describe('待发消息队列投影(queue.enqueued / queue.removed)', () => { + /** 一条入队事件:canonical 用户条目由宿主给,界面只读它派生 chip。 */ + function enqueued(clientTurnId: string, text: string, at = 1_000) { + return event({ + type: 'queue.enqueued', + clientTurnId, + at, + userItem: { + type: 'message', + role: 'user', + id: `direct-codex:${clientTurnId}:user`, + content: [{ type: 'input_text', text }], + }, + } as Partial); + } + + it('按事件顺序折成队列,取消与放行都只按身份移除', () => { + const queued = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + enqueued('client-3', '第三条'), + ]); + expect(queued.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + 'client-3', + ]); + + // 取消中间那条:其余顺序不变。 + const cancelled = reduceDirectThreadEvents(queued, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-2', + reason: 'cancelled', + }), + ]); + expect(cancelled.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-3', + ]); + + // 放行走的是同一条移除:chip 消失,回合边界由同批的 `turn.started` 给出。 + const dispatched = reduceDirectThreadEvents(cancelled, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'dispatched', + }), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + ]); + expect(dispatched.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-3', + ]); + expect(dispatched.turnRunning).toBe(true); + }); + + it('重复入队与重复移除都不改动状态引用(入队幂等、事件可能被重放)', () => { + const once = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + ]); + const twice = reduceDirectThreadEvents(once, [ + enqueued('client-1', '第一条'), + ]); + expect(twice).toBe(once); + + const removed = reduceDirectThreadEvents(twice, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removed.pendingTurns).toHaveLength(0); + const removedAgain = reduceDirectThreadEvents(removed, [ + event({ + type: 'queue.removed', + clientTurnId: 'client-1', + reason: 'cancelled', + }), + ]); + expect(removedAgain).toBe(removed); + }); + + it('队列不随回合收口消失:在队的那几条仍然是待发消息', () => { + // 一轮可以在同一次 consume 里开始又结束(放行后立刻失败),队列的推进归宿主: + // reducer 只按 `queue.removed` 撤 chip,绝不因为回合收口就自己清空。 + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + withUserItemId( + event({ type: 'turn.started', at: 2_000 }), + 'direct-codex:client-1:user', + ), + event({ type: 'turn.completed', status: 'failed', at: 2_100 }), + ]); + expect(state.turnRunning).toBe(false); + expect(state.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + ]); + }); + + it('bootstrap 把 bootstrap 那一刻的在队条目交给新订阅者', () => { + // 宿主在队条目不可回收,所以 live-set bootstrap 天然带着当前队列:另一个窗口、离开工作台 + // 再回来,看到的都是同一份(这里用 bootstrap 路径而不是实时 consume 钉住)。 + const bootstrap = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + ], + } as never, + ); + expect( + bootstrap.pendingTurns.map((turn) => turn.userItem.content), + ).toEqual([ + [{ type: 'input_text', text: '第一条' }], + [{ type: 'input_text', text: '第二条' }], + ]); + }); + }); + describe('失败终态(turn.completed 带 failure 载荷)', () => { it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index d4e741723..67afbd699 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -106,16 +106,20 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 - **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 - **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 - `turnBusyRef`/`beginTurnBusy`/`endTurnBusy`、`queueSequenceRef`、`queuedTurns*`、排队条目那部分 `pendingRunAnalyticsRef` - 与 `chatComposerQueue.ts` 的队列逻辑整体退役;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞; - 埋点句柄改由宿主在放行时开。 + `queueSequenceRef`、`queuedTurns*` 与 `chatComposerQueue.ts` 整体退役(含它的上限常量与"队列已满"文案—— + 上限只留宿主一处);`turnBusy`/`beginTurnBusy`/`endTurnBusy` 改名 `commandInFlight` 并收窄成 IPC 在飞; + `directProjectTurnStatus` 的忙态改成「原生在跑 ∨ IPC 在飞 ∨ 待发消息非空」。 + 队列投影落在 `chat/conversation/directPendingTurns.ts`,chip 文案落在 + `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts`。 - **词表切换是一次性跨文档动作**:仓库里「接单/拒单」共 384 处,并非全属同一个域 (`features/agent-runtime` 的"拒单文案"属另一个域,改成"请求被拒";`单测` 这类是假阳性)。 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 -- **埋点**:attempt id 只用于宿主内存里的候选配对与前端结算(同 `session_id` 的 `Settle` 才提交), - 前端不再需要为排队条目提前持有它;平台会话代际翻转的 `discard` 判定仍是渲染侧职责,落地时逐条核对。 +- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主(放行不重算,与其它入队检查产物一样 + 存进队列条目),但前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表, + 回合终态按本轮开口条目的 canonical 身份认领结算,入队失败的那一条直接删掉不结算。 + 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 ## 明确不做 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 150ef125a..2416050e7 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,11 +2,22 @@ 更新时间:`2026-09-24` -状态:**待实施**(设计已定稿,尚未开工) +状态:**实施中**(第 0–3 步已落地;第 4、5 步待做) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 +## 落地进度 + +| 步骤 | 状态 | 落地说明 | +| --- | --- | --- | +| 第 0 步 词表切换 | 已落地 | `rg "接单\|拒单"` 只剩 `direct_runtime/mod.rs` 对旧 ADR 文件名的引用(链接完整性,故意保留)与 `codex_app_server/mod.rs` 的一处假阳性 | +| 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | +| 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | +| 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | +| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 5 步 守卫清理 | 待做 | | + ## 第 0 步:词表切换(与代码同批,不单独提交) 「接单 / 拒单」退役,改成「入队 / 入队失败 / 放行」。逐处按角色改,**不做字面替换**: @@ -88,6 +99,26 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem ## 第 3 步:前端收口 +### 第 3 步的落地细则 + +- 队列投影是新文件 `chat/conversation/directPendingTurns.ts`:只做 `enqueuePendingTurn` / + `removePendingTurn` 两个纯函数,**不判上限、不排期、不排序**;上限与认领顺序只在宿主。 + reducer 的 `queue.enqueued` / `queue.removed` 两个分支是它唯一的调用方。 +- chip 文案派生搬到 `chat/components/DirectProjectComposer/pendingTurnChipLabel.ts` + (`pendingTurnChipLabel`),与 `chatComposerQueue.ts` 一起把"队列在本地"的最后一份实现删掉。 +- **草稿清不清由命令的入队结果回答**:`onSubmit` 改成返回 `Promise`,composer 只在 + `true` 时清草稿。返回 `false` 的口子是"用户自己就能改的入队失败"(队列已满、参数无效这类)—— + 宿主已经给了同级提示,草稿再没了就等于让用户重打一遍。写权限门让路给确认流程时返回 `true` + (内容已经在重跑的入参里),与入队化之前一致。 +- **埋点句柄按 `clientTurnId` 存成一张表**(`pendingRunAnalyticsRef`),回合终态按本轮开口条目的 + canonical 身份(`turn.completed.userItemId` ↔ `directCodexConversationMessageId(clientTurnId,'user')`) + 认领结算;身份缺失时退回结算最早的那一条(放行严格按队首顺序,收口顺序就是入队顺序)。 + 入队失败的那一轮直接把句柄删掉,不结算。 +- `displayBusy` = 原生在跑 ∨ IPC 在飞 ∨ 待发消息非空;`commandInFlight` 收窄成"IPC 在飞"。 +- 取消 chip 调 `remove_direct_project_pending_turn`,按 typed 结果 `removed / alreadyDispatched / + notFound` 说清楚;chip 的撤除仍然只认 `queue.removed` 事件(界面不改本地队列)。 + + 退役: - `chatComposerQueue.ts` 的 `enqueueChatTurn` / `dequeueChatTurn` / `removeQueuedChatTurn` / `isChatTurnQueueFull` / @@ -104,8 +135,9 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - 忙态 = 事件投影 + 「队列非空」指示;`directProjectTurnStatus` 的"命令在飞"分支收成 IPC 在飞。 - 写权限门 `ensureConversationWriteAllowed` 留在入队之前(确认框必须在用户在场时弹)。 -验收:`tests/directThreadChat.test.ts` 补队列事件投影用例;`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / -放行三组用例改成新语义;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 +验收:`tests/directThreadChat.test.ts` 补队列事件投影用例(顺序、幂等、按身份移除、bootstrap 带出在队条目、 +回合收口不清队列);`tests/appSurface/chat-composer.suite.ts` 的排队 / 取消 / 满队 / 放行四组用例改成新语义, +并新增「入队只发一次 IPC」「IPC 在飞时挡住第二次提交」两条;`npm --workspace apps/ai-game-creator-shell run typecheck` 通过。 ## 第 4 步:CLI 与夹具退役 From 25f3e416238beab87499b2327d5dab1683ce9506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:25:38 +0800 Subject: [PATCH 06/28] =?UTF-8?q?CLI=20=E4=B8=8E=E5=A4=B9=E5=85=B7?= =?UTF-8?q?=E9=80=80=E5=BD=B9=EF=BC=9ADirectProject=20=E5=8F=AA=E5=89=A9?= =?UTF-8?q?=E5=85=A5=E9=98=9F=E4=B8=80=E4=B8=AA=E5=91=BD=E4=BB=A4=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli.rs 删 CliCommand::DirectCodexChat 变体、project_path_mut 分支、--direct-codex-chat 解析与派发 - direct_runtime/mod.rs 删 run_direct_game_creator_turn_at 与 run_direct_game_creator_turn_at_with_creation_type - 删 scripts/direct-execution-production-fixture.mjs(CLI 唯一消费者) - user_input.rs 模块注释改成"唯一的命令入口",不再承诺 CLI 分工 - 顺带删掉只剩测试在用的 direct_turn_error_boundary_text,判据只剩 direct_turn_enqueue_failure 一处 - 三条命令边界测试改打 direct_turn_enqueue_failure(...).message,覆盖不变 - 文档:09-22 里程碑把 --direct-codex-chat 从"范围外(保留)"改成退役项,两份 Direct 技术方案的 CLI 承诺删掉, 09-23 ADR 的"CLI 保持 await"标为口径反转,decision-log 追加口径修正,实施计划标注第 4 步落地 --- .../direct-execution-production-fixture.mjs | 975 ------------------ .../src-tauri/src/agent/direct_runtime/mod.rs | 61 +- .../src/agent/direct_runtime/user_input.rs | 5 +- .../src-tauri/src/cli.rs | 56 - ...�ADR】DirectProject命令接单化-2026-09-23.md | 4 +- ...¡¹目对话斜杠命令与终端swarm chat入口-2026-09-22.md | 7 +- .../shared-memory/decision-log.md | 11 +- ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 2 +- ...¡ˆ】DirectProject本轮附件路径映射-2026-08-31.md | 3 +- ...术方案】Direct回合行为审计账本-2026-08-31.md | 6 +- 10 files changed, 33 insertions(+), 1097 deletions(-) delete mode 100644 apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs diff --git a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs b/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs deleted file mode 100644 index 1777dd8c9..000000000 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ /dev/null @@ -1,975 +0,0 @@ -// Real AGC CLI -> bundled app-server -> loopback Responses/MCP fixtures. -// No account credentials, installed AppData, or paid Provider are used. -import assert from 'node:assert/strict'; -import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import fs from 'node:fs/promises'; -import http from 'node:http'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repo = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../..', -); -const args = process.argv.slice(2); -const option = (name) => { - const i = args.indexOf(name); - return i < 0 ? undefined : args[i + 1]; -}; -const executable = option('--agc-exe'); -assert( - executable && path.isAbsolute(executable), - 'pass --agc-exe with the newly built debug AGC binary', -); -const codex = path.resolve( - option('--codex-exe') ?? - path.join( - repo, - 'node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe', - ), -); -const cases = ( - option('--cases') ?? 'completed,passes,mcp,mcp-write,native,deadline' -).split(','); -const root = await fs.mkdtemp( - path.join(os.tmpdir(), 'agc-execution-production-'), -); -console.log(JSON.stringify({ evidenceRoot: root })); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const hash = (value) => createHash('sha256').update(value).digest('hex'); -const exists = async (file) => - fs.stat(file).then( - () => true, - () => false, - ); -const quote = (text) => - process.platform === 'win32' - ? "'" + text.replaceAll("'", "''") + "'" - : "'" + text.replaceAll("'", "'\\''") + "'"; -const nodeCommand = (source) => - (process.platform === 'win32' ? '& ' : '') + - quote(process.execPath) + - ' -e ' + - quote( - process.platform === 'win32' - ? // Windows PowerShell 5 removes nested double quotes from native argv. - "eval(Buffer.from('" + - Buffer.from(source).toString('base64') + - "','base64').toString())" - : source, - ); -const call = (id, name, arguments_, namespace) => ({ - type: 'function_call', - id: 'item-' + id, - call_id: id, - name, - ...(namespace ? { namespace } : {}), - arguments: JSON.stringify(arguments_), -}); -// Codex 0.155 起原生执行入口是统一 exec:`exec_command` + `write_stdin`(旧 `shell_command` -// 已不再注册)。命令在 yield_time_ms 内结束时不返回 session,保持与旧用例同样的同步语义。 -const native = (id, source) => - call(id, 'exec_command', { - cmd: nodeCommand(source), - yield_time_ms: 30_000, - }); -const register = (artifact) => - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '仅测试客户端执行许可,修改临时已有工程的标记文件,不生成素材或运行游戏。', - changeKind: 'project', - requirements: [{ kind: 'artifact', id: 'marker', path: artifact }], - }, - 'mcp__agc_tools', - ); -const final = { - type: 'message', - id: 'fixture-final', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Fixture actions finished; use the host delivery result.', - }, - ], -}; -// 工具回执正文既可能是字符串,也可能是 input_text 分片数组。 -const toolOutputText = (value) => - typeof value === 'string' - ? value - : Array.isArray(value) - ? value.map((part) => part?.text ?? '').join('') - : ''; -// 原生工具的输入 schema 只随第一份请求回执留档一次,用于版本升级后核对参数形状。 -const NATIVE_SCHEMA_TOOLS = new Set([ - 'exec_command', - 'write_stdin', - 'shell_command', - 'view_image', - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', -]); -const describeTool = (tool, withSchema = false) => ({ - type: tool.type, - name: tool.name ?? tool.function?.name, - ...(tool.namespace ? { namespace: tool.namespace } : {}), - ...(withSchema && tool.parameters ? { parameters: tool.parameters } : {}), - ...(Array.isArray(tool.tools) - ? { tools: tool.tools.map((entry) => describeTool(entry, false)) } - : {}), -}); - -async function walk(directory) { - const output = []; - for (const item of await fs - .readdir(directory, { withFileTypes: true }) - .catch(() => [])) { - const file = path.join(directory, item.name); - if (item.isDirectory()) output.push(...(await walk(file))); - else if (item.isFile()) output.push(file); - } - return output; -} - -async function readLedger(host) { - for (const file of await walk(path.join(host, 'direct-executions'))) { - if (!file.endsWith('.json')) continue; - const value = await fs - .readFile(file, 'utf8') - .then(JSON.parse) - .catch(() => null); - if (value?.schemaVersion === 'agc-direct-execution.v1') - return { file, value }; - } - return null; -} - -function ownFixtureTree(directory) { - if (process.platform !== 'win32') return; - assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); - // Elevated Windows shells otherwise create Administrators-owned objects. - // Only this newly created fixture subtree is adjusted to its launching user. - const result = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - "$ErrorActionPreference='Stop'; $fixtureRoot=$env:AGC_FIXTURE_OWNER_ROOT; " + - '$fixtureSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; ' + - '$fixtureItems=@(Get-Item -LiteralPath $fixtureRoot)+@(Get-ChildItem -LiteralPath $fixtureRoot -Recurse -Force); ' + - 'foreach($fixtureItem in $fixtureItems){$fixtureAcl=$fixtureItem.GetAccessControl(); $fixtureAcl.SetOwner($fixtureSid); $fixtureItem.SetAccessControl($fixtureAcl)}', - ], - { - env: { ...process.env, AGC_FIXTURE_OWNER_ROOT: directory }, - windowsHide: true, - encoding: 'utf8', - }, - ); - assert.equal( - result.status, - 0, - 'fixture ownership setup failed: ' + result.stderr, - ); -} - -async function installFixtureMcp( - host, - directory, - readOnlyHint, - delayMs = 800, - doneMarker = '', -) { - const source = path.join(host, 'extensions', 'sources', 'fixture'); - await fs.mkdir(source, { recursive: true }); - const program = path.join(source, 'server.cjs'); - const dispatch = path.join(directory, 'mcp-dispatch.jsonl'); - await fs.writeFile( - program, - [ - "const rl=require('node:readline').createInterface({input:process.stdin});", - "const fs=require('node:fs'); const target=process.argv[2];", - "rl.on('line',async(line)=>{let x;try{x=JSON.parse(line)}catch{return}if(x.id===undefined)return;", - 'let result;switch(x.method){', - "case 'initialize':result={protocolVersion:x.params.protocolVersion,capabilities:{tools:{},resources:{}},serverInfo:{name:'fixture',version:'1'}};break;", - "case 'tools/list':result={tools:[{name:'execute',description:'Fixture execution with explicit MCP annotations',inputSchema:{type:'object',properties:{step:{type:'integer'}},required:['step']},annotations:{readOnlyHint:" + - readOnlyHint + - ',idempotentHint:true}}]};break;', - "case 'tools/call':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,arguments:x.params.arguments,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r," + - delayMs + - "));if(process.argv[3])fs.writeFileSync(process.argv[3],'done');fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result={content:[{type:'text',text:'fixture dispatched'}]};break;", - "case 'resources/list':case 'resources/templates/list':case 'resources/read':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r,800));fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result=x.method==='resources/list'?{resources:[{uri:'fixture://state',name:'state',mimeType:'text/plain'}]}:x.method==='resources/templates/list'?{resourceTemplates:[{uriTemplate:'fixture://{name}',name:'fixture',mimeType:'text/plain'}]}:{contents:[{uri:x.params.uri,text:'fixture resource',mimeType:'text/plain'}]};break;", - "case 'ping':result={};break;", - "default:process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,error:{code:-32601,message:'unsupported fixture method'}})+'\\n');return;", - "}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,result})+'\\n');});", - ].join('\n'), - ); - const config = { - command: process.execPath, - args: [program, dispatch, doneMarker], - tool_timeout_sec: 10, - }; - const document = JSON.stringify({ mcpServers: { fixture: config } }); - await fs.writeFile(path.join(source, 'mcp.json'), document); - await fs.writeFile( - path.join(host, 'extensions', 'index.json'), - JSON.stringify({ - schemaVersion: 'direct-project-client-extensions.v1', - sources: [ - { - id: 'fixture-source', - originalName: 'fixture', - storagePath: 'sources/fixture', - fingerprint: hash(document), - }, - ], - items: [ - { - id: 'fixture-mcp', - sourceId: 'fixture-source', - extensionType: 'mcp', - name: 'fixture', - originalName: 'fixture', - sourceRelativePath: 'mcp.json', - enabled: true, - fingerprint: hash(document), - lastError: null, - mcpConfig: config, - }, - ], - }), - ); - return dispatch; -} - -async function runScenario(name) { - assert( - [ - 'completed', - 'passes', - 'mcp', - 'mcp-write', - 'native', - 'native-resources', - 'patch', - 'deadline', - 'native-session', - ].includes(name), - 'unknown fixture case', - ); - const directory = path.join(root, name); - const project = path.join(directory, 'project'); - const host = path.join(directory, 'host'); - const home = path.join(directory, 'home'); - await Promise.all( - [ - project, - host, - home, - path.join(home, 'appdata'), - path.join(home, 'local'), - ].map((dir) => fs.mkdir(dir, { recursive: true })), - ); - // An existing editor project avoids turning this execution-boundary fixture - // into a new-Web-game/bootstrap/visual-quality test. - await fs.writeFile( - path.join(project, 'project.godot'), - 'config_version=5\n[application]\nconfig/name="AGC execution fixture"\n', - ); - const isMcp = name === 'mcp' || name === 'mcp-write'; - const mcpDispatch = - isMcp || name === 'native-resources' || name === 'patch' - ? await installFixtureMcp( - host, - directory, - name === 'mcp', - name === 'patch' ? 4_000 : 800, - name === 'patch' ? path.join(project, 'mcp-done.txt') : '', - ) - : null; - const writes = (filename, text) => - "require('node:fs').writeFileSync(" + - JSON.stringify(filename) + - ',' + - JSON.stringify(text) + - ');'; - const nativeOverlapCall = (id) => - native( - id, - "const f=require('node:fs');const file=" + - JSON.stringify(id + '.jsonl') + - ";const mark=phase=>f.appendFileSync(file,JSON.stringify({phase,at:Date.now()})+'\\n');mark('start');setTimeout(()=>mark('end'),1000);", - ); - const plan = - name === 'completed' - ? [ - [register('marker.txt')], - [native('marker', writes('marker.txt', 'verified'))], - [ - native( - 'forbidden-after-completion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'deadline' - ? [ - [register('never.txt')], - [ - native( - 'slow', - writes('started.txt', 'started') + - 'setTimeout(()=>{' + - writes('late.txt', 'must not run') + - '},8000);', - ), - ], - ] - : isMcp - ? [ - [register('never.txt')], - [ - call('mcp-a', 'execute', { step: 1 }, 'mcp__fixture'), - call('mcp-b', 'execute', { step: 1 }, 'mcp__fixture'), - ], - [native('fail', 'process.exit(1);')], - [call('forbidden-mcp', 'execute', { step: 2 }, 'mcp__fixture')], - ] - : name === 'native' - ? [ - [register('never.txt')], - [nativeOverlapCall('native-a'), nativeOverlapCall('native-b')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-native', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : name === 'native-resources' - ? [ - [register('never.txt')], - [ - 'list_mcp_resources', - 'list_mcp_resource_templates', - 'read_mcp_resource', - ].flatMap((tool) => - [1, 2].map((i) => - call(tool + '-' + i, tool, { - server: 'fixture', - ...(tool === 'read_mcp_resource' - ? { uri: 'fixture://state' } - : {}), - }), - ), - ), - [native('fail', 'process.exit(1);')], - ] - : name === 'native-session' - ? [ - [register('session.txt')], - [ - call('session', 'exec_command', { - cmd: nodeCommand( - "const f=require('node:fs');setTimeout(()=>f.writeFileSync('session.txt','session completed'),12000);", - ), - yield_time_ms: 10_000, - }), - ], - (body) => { - // 统一 exec 超过 yield_time_ms 会返回会话号;用 write_stdin 轮询到会话结束。 - const output = (body.input ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => toolOutputText(item.output)) - .join('\n'); - const match = /session ID (\d+)/.exec(output); - assert( - match, - 'exec_command did not return a unified exec session id: ' + - output.slice(0, 400), - ); - return [ - call('poll', 'write_stdin', { - session_id: Number(match[1]), - chars: '', - yield_time_ms: 20_000, - }), - ]; - }, - [final], - ] - : name === 'patch' - ? [ - [ - call( - 'contract', - 'agc_register_delivery_contract', - { - scope: - '验收并发补丁、计划与慢工具的正常完成;两个必需产物都必须出现。', - changeKind: 'project', - requirements: [ - { - kind: 'artifact', - id: 'patch', - path: 'patch-proof.txt', - }, - { - kind: 'artifact', - id: 'mcp', - path: 'mcp-done.txt', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - call( - 'slow-mcp', - 'execute', - { step: 1 }, - 'mcp__fixture', - ), - call( - 'patch', - 'agc_apply_patch', - { - patch: - '*** Begin Patch\n*** Environment ID: local\n*** Add File: patch-proof.txt\n+parallel patch proof\n*** End Patch', - }, - 'mcp__agc_tools', - ), - call( - 'plan', - 'agc_update_plan', - { - explanation: '并行计划回执', - plan: [ - { - step: 'parallel-plan-proof', - status: 'completed', - }, - ], - }, - 'mcp__agc_tools', - ), - ], - [ - native( - 'forbidden-patch-expansion', - writes('forbidden.txt', 'must not run'), - ), - ], - ] - : [ - [register('never.txt')], - [native('build', 'console.log(1+1);')], - [native('test', 'console.log(2+2);')], - [native('fail', 'process.exit(1);')], - [ - native( - 'forbidden-after-budget', - writes('forbidden.txt', 'must not run'), - ), - ], - ]; - const requests = []; - const responses = []; - const httpArrivals = []; - const fixtureErrors = []; - const server = http.createServer(async (request, response) => { - httpArrivals.push({ - at: Date.now(), - method: request.method, - url: request.url, - expectedAuthorization: - request.headers.authorization === 'Bearer agc-loopback-fixture', - }); - try { - assert.equal(request.method, 'POST'); - assert(request.url.endsWith('/responses')); - assert.equal( - request.headers.authorization, - 'Bearer agc-loopback-fixture', - ); - const chunks = []; - let bytes = 0; - for await (const chunk of request) { - bytes += chunk.length; - assert(bytes <= 32 * 1024 * 1024, 'oversized fixture request'); - chunks.push(chunk); - } - const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const index = requests.length; - requests.push({ - index, - at: Date.now(), - model: requestBody.model, - parallelToolCalls: requestBody.parallel_tool_calls, - ...(index === 0 - ? { - toolCatalogue: (requestBody.tools ?? []).map((tool) => - describeTool(tool, NATIVE_SCHEMA_TOOLS.has(tool.name)), - ), - } - : {}), - // 只留档工具回执的有界原文,用于核对统一 exec 的会话/退出结果契约。 - inputs: (requestBody.input ?? []).map((item) => ({ - type: item.type, - name: item.name, - call_id: item.call_id, - raw: JSON.stringify(item).slice(0, 1500), - })), - }); - assert(index < 20, 'fixture model loop exceeded bound'); - if (index === plan.length - 1 && name !== 'deadline') { - // Allow host receipt settlement/sealing to win before an intentionally - // unwanted expansion. A closed transport is a valid pre-dispatch stop. - await sleep(300); - } - const step = plan[index] ?? [final]; - const output = typeof step === 'function' ? step(requestBody) : step; - - responses.push({ - index, - items: output.map((item) => JSON.stringify(item).slice(0, 300)), - }); - const body = { - id: 'response-' + index, - object: 'response', - model: 'gpt-5.1-codex', - status: 'completed', - output, - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - }; - const events = [ - { - type: 'response.created', - response: { ...body, status: 'in_progress', output: [] }, - }, - ]; - output.forEach((item, output_index) => { - events.push({ type: 'response.output_item.added', output_index, item }); - events.push({ type: 'response.output_item.done', output_index, item }); - }); - events.push({ type: 'response.completed', response: body }); - response.writeHead(200, { 'content-type': 'text/event-stream' }); - if (name === 'patch' && index === 1) { - // One response, streamed incrementally: prove the long operation really - // started before offering independent patch/plan calls to the scheduler. - response.write( - events - .slice(0, 3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - const startedDeadline = Date.now() + 5_000; - while (Date.now() < startedDeadline) { - const log = await fs.readFile(mcpDispatch, 'utf8').catch(() => ''); - if ( - log - .split('\n') - .some( - (line) => - line.includes('"method":"tools/call"') && - line.includes('"phase":"start"'), - ) - ) - break; - await sleep(20); - } - response.end( - events - .slice(3) - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - return; - } - response.end( - events - .map((event) => 'data: ' + JSON.stringify(event) + '\n\n') - .join(''), - ); - } catch (error) { - fixtureErrors.push(String(error)); - if (!response.destroyed) { - response.writeHead(400); - response.end(String(error)); - } - } - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - await fs.writeFile( - path.join(host, 'game-creator.config.json'), - JSON.stringify({ - schemaVersion: 'game-creator-config.v2', - agentMode: 'codex_app_server', - llm: { - customEnabled: true, - apiKey: 'agc-loopback-fixture', - baseUrl: 'http://127.0.0.1:' + address.port + '/v1', - model: 'gpt-5.1-codex', - visibleModels: ['gpt-5.1-codex'], - apiKind: 'openai_responses', - stream: true, - webSearchEnabled: false, - reasoningEffort: 'low', - maxRetries: 0, - requestTimeoutMs: 60_000, - }, - validation: { - maxRuns: 1, - maxExecutionSeconds: name === 'deadline' ? 2 : 30, - maxTurnSeconds: 60, - }, - }), - ); - ownFixtureTree(directory); - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([key]) => - !/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION|COOKIE/i.test(key), - ), - ); - Object.assign(env, { - GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', - HOME: home, - USERPROFILE: home, - APPDATA: path.join(home, 'appdata'), - LOCALAPPDATA: path.join(home, 'local'), - PATH: path.dirname(codex) + path.delimiter + (env.PATH ?? env.Path ?? ''), - }); - let stdout = '', - stderr = ''; - let planObservedAt = null; - let observingPlan = false; - let planObservationTask = Promise.resolve(); - const planWatch = - name === 'patch' - ? setInterval(() => { - if (planObservedAt !== null || observingPlan) return; - observingPlan = true; - planObservationTask = readLedger(host) - .then((entry) => { - if ( - JSON.stringify(entry?.value.plan ?? null).includes( - 'parallel-plan-proof', - ) - ) - planObservedAt = Date.now(); - }) - .catch(() => {}) - .finally(() => { - observingPlan = false; - }); - }, 20) - : null; - const child = spawn( - executable, - [ - '--config-dir', - host, - '--direct-codex-chat', - project, - '测试现有临时工程的宿主执行许可;只按fixture合同操作,不生成平台素材。', - ], - { - cwd: directory, - env, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - child.stdout.on('data', (data) => { - stdout = (stdout + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - child.stderr.on('data', (data) => { - stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024); - }); - const timeout = setTimeout(() => child.kill(), 90_000); - const exit = await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('exit', (code, signal) => resolve({ code, signal })); - }).finally(() => clearTimeout(timeout)); - if (planWatch) clearInterval(planWatch); - await planObservationTask; - server.closeAllConnections(); - await new Promise((resolve) => server.close(resolve)); - if (name === 'deadline') await sleep(9_000); - const ledger = await readLedger(host); - const dispatchEvents = mcpDispatch - ? (await fs.readFile(mcpDispatch, 'utf8').catch(() => '')) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse) - : []; - const dispatched = dispatchEvents.filter( - (entry) => entry.phase === 'start' && entry.method === 'tools/call', - ); - const intervals = dispatched.map((entry) => ({ - id: entry.id, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const overlapMs = - intervals.length === 2 && intervals.every((entry) => entry.end) - ? Math.min(...intervals.map((entry) => entry.end)) - - Math.max(...intervals.map((entry) => entry.start)) - : null; - const nativeIntervals = []; - if (name === 'native') - for (const id of ['native-a', 'native-b']) { - const entries = ( - await fs - .readFile(path.join(project, id + '.jsonl'), 'utf8') - .catch(() => '') - ) - .trim() - .split('\n') - .filter(Boolean) - .map(JSON.parse); - nativeIntervals.push({ - id, - start: entries.find((entry) => entry.phase === 'start')?.at, - end: entries.find((entry) => entry.phase === 'end')?.at, - }); - } - const nativeOverlapMs = - nativeIntervals.length === 2 && nativeIntervals.every((entry) => entry.end) - ? Math.min(...nativeIntervals.map((entry) => entry.end)) - - Math.max(...nativeIntervals.map((entry) => entry.start)) - : null; - const resourceIntervals = dispatchEvents - .filter( - (entry) => - entry.phase === 'start' && - entry.method.startsWith('resources/') && - entry.at >= (requests[1]?.at ?? Infinity), - ) - .map((entry) => ({ - id: entry.id, - method: entry.method, - start: entry.at, - end: dispatchEvents.find( - (event) => event.id === entry.id && event.phase === 'end', - )?.at, - })); - const resourceOverlaps = Object.fromEntries( - ['resources/list', 'resources/templates/list', 'resources/read'].map( - (method) => { - const matching = resourceIntervals.filter( - (entry) => entry.method === method, - ); - return [ - method, - matching.length === 2 && matching.every((entry) => entry.end) - ? Math.min(...matching.map((entry) => entry.end)) - - Math.max(...matching.map((entry) => entry.start)) - : null, - ]; - }, - ), - ); - const report = { - scenario: name, - directory, - exit, - requests, - httpArrivals, - fixtureErrors, - ledger: ledger?.value, - responses, - dispatched, - dispatchEvents, - intervals, - overlapMs, - mcpReadOnlyHint: mcpDispatch ? name === 'mcp' : undefined, - nativeIntervals, - nativeOverlapMs, - resourceIntervals, - resourceOverlaps, - planObservedAt, - patchModifiedAt: await fs.stat(path.join(project, 'patch-proof.txt')).then( - (stat) => stat.mtimeMs, - () => null, - ), - stdout, - stderr, - markers: { - ready: await exists(path.join(project, 'marker.txt')), - forbidden: await exists(path.join(project, 'forbidden.txt')), - started: await exists(path.join(project, 'started.txt')), - late: await exists(path.join(project, 'late.txt')), - }, - }; - await fs.writeFile( - path.join(directory, 'result.json'), - JSON.stringify(report, null, 2), - ); - assert( - requests.length > 0, - name + ': AGC did not reach the loopback fixture', - ); - assert( - requests.every((request) => request.parallelToolCalls === true), - name + - ': Direct Responses requests must allow multiple tool calls without changing the selected model', - ); - assert(ledger, name + ': missing host-authoritative ledger'); - assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json'); - assert.equal( - report.markers.forbidden, - false, - name + ': effect ran after host terminal', - ); - assert.equal( - ledger.value.usedPasses, - 1, - name + ': ordinary commands must share one pass', - ); - assert.equal( - ledger.value.phase, - ['completed', 'patch', 'native-session'].includes(name) - ? 'completed' - : 'exhausted', - ); - assert.equal( - ledger.value.executorStopped, - true, - name + ': missing full executor exit proof', - ); - if (name === 'completed') assert.equal(report.markers.ready, true); - if (isMcp) { - assert.equal( - dispatched.length, - 2, - 'untrusted readonly hints cannot authorize a post-terminal MCP call', - ); - assert(dispatched.every((entry) => entry.arguments.step === 1)); - assert( - overlapMs > 0, - name + - ': real Codex MCP calls ran serially; inspect start/end evidence before changing dispatch policy', - ); - } - if (name === 'deadline') { - assert.equal( - report.markers.started, - true, - 'must prove the real child started before the cutoff', - ); - assert.equal( - report.markers.late, - false, - 'turn interruption alone did not stop the child', - ); - } - if (name === 'native') - assert( - nativeOverlapMs > 0, - 'independent native write commands must overlap', - ); - if (name === 'native-resources') - for (const [method, overlap] of Object.entries(resourceOverlaps)) - assert( - overlap > 0, - method + ': real native resource reads did not overlap', - ); - if (name === 'patch') { - const catalogue = requests[0].toolCatalogue; - assert( - !catalogue.some((tool) => - ['apply_patch', 'update_plan', 'request_user_input'].includes( - tool.name, - ), - ), - 'serial native registrations must be absent', - ); - const owned = - catalogue.find((tool) => tool.name === 'mcp__agc_tools')?.tools ?? []; - for (const tool of ['agc_apply_patch', 'agc_update_plan']) - assert(owned.some((entry) => entry.name === tool)); - assert.equal( - intervals.length, - 1, - 'exactly one slow third-party call must execute', - ); - assert.equal( - await fs.readFile(path.join(project, 'patch-proof.txt'), 'utf8'), - 'parallel patch proof\n', - ); - assert.equal( - await fs.readFile(path.join(project, 'mcp-done.txt'), 'utf8'), - 'done', - ); - for (const [action, at] of Object.entries({ - patch: report.patchModifiedAt, - plan: planObservedAt, - })) - assert( - at > intervals[0].start && at < intervals[0].end, - action + ' did not complete while the slow MCP call was running', - ); - } - if (name === 'native-session') { - const execOutput = requests - .flatMap((entry) => entry.inputs ?? []) - .filter((item) => item.call_id === 'session') - .map((item) => { - try { - return toolOutputText(JSON.parse(item.raw).output); - } catch { - return ''; - } - }) - .join('\n'); - assert( - /Process running with session ID \d+/.test(execOutput), - 'exec_command must return a unified exec session id', - ); - assert( - await exists(path.join(project, 'session.txt')), - 'write_stdin polling must let the unified exec session finish', - ); - assert.equal( - ledger?.value?.phase, - 'completed', - 'host acceptance must complete after the session artifact appears', - ); - } - console.log( - JSON.stringify({ - scenario: name, - passed: true, - result: path.join(directory, 'result.json'), - }), - ); -} - -const failures = []; -for (const name of cases) { - try { - await runScenario(name); - } catch (error) { - failures.push({ scenario: name, error: String(error) }); - console.error(JSON.stringify(failures.at(-1))); - } -} -assert.equal( - failures.length, - 0, - 'production fixture failures: ' + JSON.stringify(failures), -); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index fdf0ded68..3fa5066b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2148,24 +2148,10 @@ pub(crate) fn record_direct_codex_failure( public_text } -/// 命令边界的错误文本:可留痕的调用级入队失败在这里补一份运行错误诊断(文案里不带诊断引用), -/// 其余只输出 [`DirectTurnError`] 的 `Display`。 -/// -/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 -/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据; -/// 回合级失败已在上游写过诊断,这里直接放行。 -pub(crate) fn direct_turn_error_boundary_text( - root: &Path, - client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> String { - direct_turn_enqueue_failure(root, client_turn_id, failure).message -} - /// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 /// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 /// -/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 +/// 命令边界只有这一份判据,禁止在各处再写一套。 pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, @@ -4432,39 +4418,6 @@ pub(crate) fn check_direct_turn_preconditions( .map_err(|detail| DirectTurnError::InputRejected { detail })?; Ok(()) } -pub(crate) async fn run_direct_game_creator_turn_at( - root: &Path, - prompt: &str, -) -> Result { - // The CLI entry point does not receive the GUI's clientTurnId. Still arm - // one invocation identity so an otherwise optional AGC generation tool - // cannot fail merely because the request came through the CLI. This is - // local execution identity only; it does not create a Runtime task or DAG. - let invocation_id = format!("direct-cli-{}", unix_millis()); - let _invocation = DirectTaonierActiveInvocationGuard::enter(root, &invocation_id)?; - run_direct_game_creator_turn_at_with_creation_type(root, prompt, None).await -} - -pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( - root: &Path, - prompt: &str, - creation_type: Option<&str>, -) -> Result { - // CLI 入口没有"入队 / 放行"这两步(它 await 整轮,要那段回复文本),前置条件在这里自己过一遍; - // GUI 命令在入队半里过同一份判据。 - check_direct_turn_preconditions(root, prompt, creation_type)?; - run_direct_game_creator_turn_at_with_creation_type_and_emitter( - root, - prompt, - creation_type, - None, - None, - None, - None, - ) - .await -} - pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( root: &Path, prompt: &str, @@ -7739,13 +7692,14 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, Some("direct-codex:turn-1:user"), DirectTurnError::EnvironmentNotReady { detail: "Codex app-server 启动失败:找不到可执行文件".into(), }, - ); + ) + .message; assert!(text.contains("direct-codex-failure:v2"), "{text}"); assert!(!text.contains("详情:"), "{text}"); @@ -7765,7 +7719,7 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; assert_eq!(text, "聊天内容不能为空"); assert!(!root.join(".agent/runtime/errors").exists()); @@ -7782,11 +7736,12 @@ mod tests { DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); let recorded = record_direct_codex_failure(&root, &failure, None); // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_error_boundary_text( + let text = direct_turn_enqueue_failure( &root, None, DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ); + ) + .message; assert_eq!(text, recorded); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index de0bdc053..2ea1be4d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -39,9 +39,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 /// 错误诊断,返回串不带诊断引用。 /// -/// 与 CLI 的分工:CLI 入口(`cli.rs` 的 `direct-codex.chat`)**保持 await**——它要把那段回复文本 -/// 打到终端上,没有事件订阅可用;两个入口共用 [`direct_turn_error_boundary_text`] / -/// [`direct_turn_enqueue_failure`],不要再各写一套判据。 +/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 +/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 /// /// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 05c532102..1850fe457 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -14,10 +14,6 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, - DirectCodexChat { - project_path: PathBuf, - prompt: String, - }, AgentTask { project_path: PathBuf, agent_id: String, @@ -178,7 +174,6 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } - | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -699,19 +694,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } - if args.first().map(String::as_str) == Some("--direct-codex-chat") { - if args.len() < 3 { - return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); - } - let prompt = args[2..].join(" "); - if prompt.trim().is_empty() { - return Err("聊天内容不能为空".to_string()); - } - return Ok(Some(CliCommand::DirectCodexChat { - project_path: PathBuf::from(&args[1]), - prompt: prompt.trim().to_string(), - })); - } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -896,44 +878,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } - CliCommand::DirectCodexChat { - project_path, - prompt, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; - if !project_path.join(".agent/manifest.json").is_file() { - let project_name = project_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("Codex 直连项目"); - init_local_game_project_at( - &project_path, - &format!("direct-codex-{}", unix_millis()), - project_name, - )?; - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; - let reply_result = runtime.block_on(async { - run_direct_game_creator_turn_at(&project_path, &prompt) - .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 - // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影(同一份 `Display` 文案, - // 不另加 `详情:` 引用),差别只在 CLI 自己 await 整轮、拿到回复文本。 - .map_err(|failure| { - direct_turn_error_boundary_text(&project_path, None, failure) - }) - }); - let shutdown_result = shutdown_game_creator_codex_app_servers(); - let reply = reply_result?; - shutdown_result?; - println!("direct-codex.chat.completed"); - println!("projectPath={}", project_path.display()); - println!("replyText={reply}"); - Ok(()) - } CliCommand::AgentTask { project_path, agent_id, diff --git a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md index 5dc829f89..7069264bc 100644 --- a/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md +++ b/docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md @@ -110,7 +110,9 @@ 界面不会卡在忙碌态。 - 必须同步的注释:`chat/controller/useDirectProjectChatController.ts`(catch 的职责)、 `chat/conversation/directTurnPresentation.ts`("`invoke` 直到整轮结束才返回"这句会变成错的)。 -- CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。 +- ~~CLI 保持 await(它要那段回复文本),两个入口的分工在命令模块里写清楚。~~ **口径反转**:`--direct-codex-chat` + 整个退役(见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](./【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + DirectProject 只剩「入队」一个命令入口。 ## 备选方案与取舍 diff --git a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md index 581077bf8..576fc818c 100644 --- a/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md +++ b/docs/project-memory/plans/【里程碑】退役AGC项目对话斜杠命令与终端swarm chat入口-2026-09-22.md @@ -36,7 +36,12 @@ - 命令 id 注册表 `GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`(项目权限策略词汇表;App 前端只用 `GameCreationAppCommandDescriptor` 类型表达权限判定与审计粒度,数组本体由 Rust 策略路径消费)。 - `needsInitializedChatProject` 与项目权限策略链路。 - Rust 侧路径/路由的 `/` 前缀校验。 -- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check`、`--direct-codex-chat` 等运维与开发 CLI 控制命令。 +- `--agent-run`、`--agent-enqueue`、`--agent-task`、`--agent-steer`、`--agent-retry`、`--agent-cancel`、`--agent-confirm`、`--agent-resume`、`--agent-runtime-status`、`--agent-goal-*`、`--agent-context-compact`、`--runner-status`、`--runner-shutdown-if-idle`、`--llm-status`、`--preview-serve`、`--environment-check` 等运维与开发 CLI 控制命令。 + +已退役(口径变更,见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)): +- `--direct-codex-chat`(本文件此前列为「范围外(保留)」):DirectProject 命令入队化之后不再保留第二条直接起回合的入口, + 它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`,两者一起退役;同批删掉只服务它的 + `run_direct_game_creator_turn_at` / `run_direct_game_creator_turn_at_with_creation_type`。 - 保留的真实 E2E 套件:`supervisor-swarm`、`supervisor-swarm-transient-retry`、`supervisor-swarm-final-reply-transient-retry`、`supervisor-swarm-tool-plan-handoff-runner-kill`、`goal-runtime`、`response-stream`、`web-search`、`context-compaction`、`scoped-agents`、`project-skill`、`parallel-read`、`steer-runner-kill`、`process-session`。 ## 检查点 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8304d518..b8089f910 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9491,7 +9491,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(队列与埋点听回合终态,不听命令返回):前端发送队列的放行改由"回合完成(终态事件)或接单被拒"驱动,reducer 新增 `completedTurnCount` 作为唯一判据——不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束。埋点结算同样挂到回合终态:不能在接单返回时结算,成绩是回合末才入 `pending_runs`,提前结算会变成空操作;`runTurn` 返回"是否接单",接单失败的路径只清句柄、不结算。**加 TODO:这条队列以后挪到 Rust 端,落点就是 Thread Manager 的接单动作。** 首页"运行中的项目"快照改由 TM 的逻辑回合导出,任务侧不再单独维护一张表。 - 决策(认证失败不再重跑整轮):删掉 `withDirectCodexSessionRefresh` 的"刷新 + 重跑整轮"(重跑会重复落盘用户消息),登录态失效按普通回合失败呈现;用同一个包装的 `cancel_direct_codex_turn` 一并去掉。 - 决策(失败原因本轮不落历史):失败原因只走事件载荷与宿主诊断(`.agent/runtime/errors` + 应用日志 + 错误上报池由宿主投影写出,进池责任从前端 catch 移到宿主),不写进 `project.jsonl`——重进项目只会看到那条没有回复的用户消息。**加 TODO(暂定做法见 ADR 备选方案第 3 条 (b)):以后要做"进历史但不喂模型"的失败条目,本轮明确不持久化。** -- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。 +- 决策(CLI 保持 await):CLI 入口(`cli.rs` 的 `direct-codex.chat`)继续 await 整轮,因为它要把回复文本打到终端、没有事件订阅可用;两个入口共用同一份接单前检查、同一个命令主体和同一份 `Display` 文案,不各写一套判据。**(口径修正:本条已作废——2026-09-24 的 `--direct-codex-chat` 退役把 CLI 入口整个删掉,DirectProject 只剩「入队」一个命令入口;见本文件同日「命令入队化」那条与 `【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`。)** - 明确不做:不给失败载荷加字段(不加 `detailRef`);不恢复 invoke 拒绝通道,也不为"接单后的前置失败"新增事件类型;本轮不做"失败条目进历史但不喂模型"(TODO)、不做 Rust 端发送队列(TODO)。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_accept.rs,direct_thread_manager.rs,direct_thread_wire.rs,direct_turn_error.rs,direct_turn_failure.rs,direct_project_context.rs,direct_runtime/{mod.rs,user_input.rs},codex_app_server/mod.rs,runtime_driver/entrypoints.rs,cli.rs}`、前端 `chat/{controller/useDirectProjectChatController.ts,controller/useDirectProjectTurnStatus.ts,controller/useDirectThreadChatSubscription.ts,conversation/directCodexConversation.ts,conversation/directThreadChat.ts,conversation/directTurnPresentation.ts}`、`chat/generated/{DirectTurnError,DirectTurnRejection,DirectThreadEvent,...}.ts` 与 `tests/{directThreadChat.test.ts,appSurface/*.suite.ts}`;文档 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md`、`docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md`。 - 已知坑:`cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` 掉不是本次新增的文件;本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 @@ -9598,11 +9598,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 再落盘用户条目、下发用户条目、起整轮。**放行不重跑检查、不存在放行失败**,放行之后的一切失败都是回合失败, 走既有 `turn.completed.failure`,不新增通道。kick 点 = 回合任务收尾(含 drop 守卫盖 panic)+ 中止路径 + 入队之后, 幂等且在临界区里认领队首;入队不取 `DirectTaonierActiveInvocationGuard`(它必须整轮持有,是这一轮的调用身份)。 -- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿"); - 埋点句柄改由宿主在放行时开。 +- 决策(前端):不再持有队列副本,chip 只由事件投影,入队失败只由命令返回值给反馈(保留提示文案与"不丢草稿")。 + **(落地修正,2026-09-24)**:埋点句柄仍由前端在入队那一刻生成并随命令交给宿主(放行不重算,与其它入队检查产物一起 + 存进队列条目),前端把句柄按 `clientTurnId` 存成一张表、回合终态按本轮开口条目的 canonical 身份认领结算; + "不丢草稿"由「提交等命令的入队结果、只有用户自己能改的入队失败才返回 false」实现(不再是"宿主在放行时开句柄")。 - 决策(顺带退役):`--direct-codex-chat` 整个退役——它唯一的实际消费者是手工夹具 `scripts/direct-execution-production-fixture.mjs`(PR #439 引入、不在 CI、无 npm/harness 注册、无测试钉它), - 夹具一并退役;`TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"分支随之删除。 + 夹具一并退役。**(落地修正,2026-09-24)**:`TurnAlreadyRunning` 的最后一个生产点在调用身份守卫里 + (占用登记那条早已随入队化消失),所以它连同前端"同一轮消息仍在处理中"分支一起放到**守卫清理那一步**再删。 `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 2416050e7..bfae9ff8c 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -15,7 +15,7 @@ | 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`,带上入队时产出的 `prompt` / `creation_type` / `analytics_attempt_id` | | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | -| 第 4 步 CLI 与夹具退役 | 待做 | | +| 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | | 第 5 步 守卫清理 | 待做 | | ## 第 0 步:词表切换(与代码同批,不单独提交) diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md index 3fa638824..0e34cdbc3 100644 --- a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -165,7 +165,8 @@ Supervisor / 做方案首轮忽略 `attachments`,行为不变。 - `chat_with_game_creator_direct_codex` 增加 `attachments: Option>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` - 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现 -不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。 +不要把 attachments 顺着 inner turn / emitter 往下传。(CLI 入口 `run_direct_game_creator_turn_at` 已随命令入队化退役,见 +[`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。) ### 5.2 前端 diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md index d01893b3a..68be727f2 100644 --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md @@ -38,7 +38,7 @@ Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动 - 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。 - 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。 - 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。 -- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。 +- DirectHome、ToolHost 本期不写这份账本。(`--direct-codex-chat` 已随命令入队化退役,不再是入口。) - 本期不改 UI,不在聊天面板展示审计。 - 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。 @@ -292,7 +292,9 @@ chat_with_game_creator_direct_codex → 写 turn_end + agent.db 摘要 ``` -- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。 +- CLI 入口已退役:`run_direct_game_creator_turn_at` 与 `--direct-codex-chat` 一起删除(见 + [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)), + 所以「无 `clientTurnId` 的 Direct 回合」这条路径不存在了。 - Home command 不接 audit。 - 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。 - `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。 From 41c03d7199851e7b757fe0ff0c5e7e982ae3bf59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:30:16 +0800 Subject: [PATCH 07/28] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=A0=B8?= =?UTF-8?q?=E5=AE=9E=E5=88=A0=E8=B0=83=E7=94=A8=E8=BA=AB=E4=BB=BD=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E7=9A=84=E5=89=8D=E7=BD=AE=E5=88=A4=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=AC=AC=205=20=E6=AD=A5=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR §9 记录核实结论:取消兜底本来就同时释放占用、补写 turn.completed 并踢队列,删守卫不需要新增无条件终态;连接死亡的失败事实由 app-server 连接层落地 - 实施计划第 5 步改写:60 秒启动窗口闸门改挂 Thread Manager 的占用登记年龄,读者改读 direct_active_turn_id_at,TurnAlreadyRunning 一并删 - decision-log 追加 2026-09-30 条目,记录核实过程、决策与影响范围 --- ...命令入队化与待发消息队列归宿主-2026-09-24.md | 12 ++++++--- .../shared-memory/decision-log.md | 21 +++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 27 ++++++++++++------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 67afbd699..90d8b2735 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -85,9 +85,15 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - CLI 一退,`DirectTurnError::TurnAlreadyRunning` 的两个生产点(调用身份守卫、占用登记)都没有调用方, 它连同前端"同一轮消息仍在处理中"文案、专属分支与测试一起删。 - `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 - (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:第二步让那五个读者 - 改读 Thread Manager 的活动回合身份,守卫连同它的测试与 60 秒卡死兜底一起删。删之前必须确认取消路径的无条件终态 - 仍然解得开"回合卡死"(那次兜底来自真实事故 `d833ca9d3`)。 + (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:五个读者 + (`direct_execution` / `direct_tool_bridge` / `direct_validation` / `direct_project_context` / 付费美术重生成) + 改读 Thread Manager 的活动回合身份,守卫连同它的测试一起删,不再有第二份进程内记录。 +- 删守卫的前置判据**已核实(2026-09-30)**,不需要新增"取消路径补无条件终态":`d833ca9d3` 的兜底路径本来就 + 同时做三件事——释放占用、往事件流补一条 `turn.completed{aborted}`(否则前端会永远停在运行中)、调 + `complete_direct_thread_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; + 唯一独有的东西是 60 秒启动窗口闸门,改挂在 Thread Manager 的占用登记年龄上 + (`direct_stale_turn_for_release` 的 `NeverReachedExecutor` 分支)。连接死亡那条失败事实由 app-server + 连接层自己落地(`ab970b9fd`),与守卫无关。 ## 备选方案与取舍 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b8089f910..66dc906c3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9614,3 +9614,24 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 失去"真实二进制驱动执行层生产验证"这条手工路径。 - 验证方式(设计稿,尚未实施):`docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`、 `docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。 + +## 2026-09-30 守卫清理前置判据核实:取消兜底不需要新增无条件终态,60 秒闸门改挂占用登记年龄 + +- 背景:按 09-24 ADR §9 的计划,删 `DirectTaonierActiveInvocationGuard` 前必须确认"回合任务 park / 泄漏时, + 取消仍能清空占用并让队列继续放行"。查 git 历史(`d833ca9d3` / `ab970b9fd`)与当前实现后确认: + `cancel_direct_codex_turn_at` 的兜底路径本来就同时做三件事——释放占用、往事件流补一条 + `turn.completed{aborted}`(否则前端会永远停在运行中)、调 `complete_direct_thread_turn` 解除占用并踢队列。 + 所以删守卫**不需要**新增"取消路径补无条件终态",那是当时就有的东西,不是守卫带来的。 +- 决策:守卫只在两件事上是独有的——(1) 它是"这一轮是谁"的第二份进程内记录(与 Thread Manager 的 + `active_turn.turn_id` 同源),CLI 退役后已无第二个入口,按"同一件事只许有一处真相"删; + (2) 它的 60 秒启动窗口闸门(防"刚放行、还在本地准备的回合被终止误伤")保留,改挂在 Thread Manager + 的占用登记年龄上:`direct_stale_turn_for_release(root, expected, reason)` 只在 + `NeverReachedExecutor` 时校验年龄(`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`),返回身份;释放本身仍由 + 既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成(天然幂等)。 +- 决策:连接死亡的失败事实由 app-server 连接层自己落地(`ab970b9fd`),与守卫无关,删除守卫不动那条链路。 +- 影响范围:`agent/direct_runtime/mod.rs`(删守卫表 / 只读探测,改为 `direct_active_turn_id_at`)、 + `agent/direct_thread_manager.rs`(`DirectTurnIdentity` + `read_direct_turn_identity`)、 + `agent/codex_app_server/mod.rs`(兜底目标改 `DirectStaleTurnReleaseReason`)、五个身份读者与相关测试、 + 删 `DirectTurnError::TurnAlreadyRunning` 与前端对应分支 / 生成绑定。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`;`npx vitest run tests/appSurface.test.ts`; + `npm --workspace apps/ai-game-creator-shell run typecheck`;`npm run check:encoding`;`git diff --check`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index bfae9ff8c..8a7dc67f6 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,7 +2,7 @@ 更新时间:`2026-09-24` -状态:**实施中**(第 0–3 步已落地;第 4、5 步待做) +状态:**实施中**(第 0–4 步已落地;第 5 步进行中) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -16,7 +16,7 @@ | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | -| 第 5 步 守卫清理 | 待做 | | +| 第 5 步 守卫清理 | 进行中 | 前置判据已核实;改读 Thread Manager 活动回合身份、删调用身份守卫与其测试,并删 `TurnAlreadyRunning` | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -155,14 +155,21 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem ## 第 5 步:守卫清理(CLI 退役之后) -- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成、 - `agent/direct_validation.rs`、`agent/direct_project_context.rs`)改读 Thread Manager 的活动回合身份 - (新增一个按项目路径取 `active_turn.turn_id` 的只读入口)。 -- 删 `DirectTaonierActiveInvocationGuard` 与 `release_stale_direct_taonier_active_invocation` 及其测试 - (`direct_runtime/mod.rs` 的守卫用例、`direct_project_context.rs`、`direct_tool_bridge.rs`、`direct_tools_mcp.rs`、 - `user_input.rs`、`codex_app_server/mod.rs` 的集成用例),取消路径改为直接走无条件的 `complete_direct_thread_turn("aborted")`。 -- 前置判据:补一条测试证明"回合任务被 park / 泄漏时,取消仍能清空占用并让队列继续放行"——那次 60 秒兜底来自真实事故 - (`d833ca9d3`),删它必须有等价保证。 +> 前置判据**已核实(2026-09-30)**:`d833ca9d3` 的取消兜底本来就同时做三件事——释放占用、往事件流补一条 +> `turn.completed{aborted}`(否则前端永远停在运行中)、解除 `complete_direct_thread_turn` 占用;所以删守卫 +> **不需要**新增任何"取消路径补无条件终态"。连接死亡那条失败事实由 app-server 连接层自己落地(`ab970b9fd`), +> 也与守卫无关。守卫唯一独有的东西是 60 秒启动窗口闸门,改挂在 Thread Manager 的占用登记年龄上。 + +- 五个身份读者(`agent/direct_execution.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_validation.rs`、 + `agent/direct_project_context.rs`、`agent/direct_runtime/mod.rs` 的付费美术重生成)改读 + `direct_active_turn_id_at`(Thread Manager 的活动回合,只读、不改占用)。 +- 删 `DirectTaonierActiveInvocationGuard`、`DirectActiveTurnView`、`read_direct_taonier_active_invocation_at` 与 + `release_stale_direct_taonier_active_invocation` 及它们的测试;`codex_app_server` 的兜底目标改 + `DirectStaleTurnReleaseReason`,释放本身仍由既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成。 +- 删 `DirectTurnError::TurnAlreadyRunning`(最后一个生产点在守卫里)与前端"同一轮消息仍在处理中"文案、专属分支、 + 生成绑定与 `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- 前置判据测试:`cancel_direct_codex_turn_at` 在"回合任务泄漏、app-server 侧没有可中断句柄"时仍能解除占用, + 并把队首放行出去(`codex_app_server` 的兜底用例 + `direct_stale_turn_for_release` 的单测)。 ## 验收与证据 From ecac9d8e15e5dd94766a8e569048baeb52bcbe76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:39:32 +0800 Subject: [PATCH 08/28] =?UTF-8?q?DirectProject=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=BD=92=20Thread=20Manager=EF=BC=9A?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E7=AC=AC=E4=BA=8C=E4=BB=BD=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=86=85=E5=AE=88=E5=8D=AB=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Thread Manager 的只读身份入口:DirectTurnIdentity + read_direct_turn_identity,占用仍只由终态出口解除 - 五个身份读者(执行会话、工具桥、校验预约、上下文预取、付费美术重生成)改读 direct_active_turn_id_at - 删 DirectTaonierActiveInvocationGuard / DIRECT_TAONIER_ACTIVE_INVOCATIONS / DirectActiveTurnView 与只读探测 - 删 release_stale_direct_taonier_active_invocation,改为 direct_stale_turn_for_release 只做前置校验(身份一致 + 启动窗口),释放仍由 complete_direct_thread_turn + kick_direct_queue_dispatch 完成 - DirectStaleTurnReleaseReason 取代 DirectTaonierStaleGuardReason;终止兜底的注释与文案改成占用口径 - 放行不再另取调用身份:认领时已在 Thread Manager 登记,删掉认领后的守卫进入与失败分支 - 新增前置判据用例 stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn,并补背景拨时测试钩子 - 集成用例改用 DirectTurnReservation::accept_for_test 建占用,不再进守卫 --- .../src/agent/codex_app_server/mod.rs | 45 +-- .../src-tauri/src/agent/direct_execution.rs | 2 +- .../src/agent/direct_project_context.rs | 9 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 381 +++++------------- .../src/agent/direct_thread_manager.rs | 54 +++ .../src-tauri/src/agent/direct_tool_bridge.rs | 9 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 10 +- .../src/agent/direct_turn_dispatch.rs | 64 ++- .../src-tauri/src/agent/direct_validation.rs | 4 +- 9 files changed, 229 insertions(+), 349 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 4538e1c57..4af07737d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -4428,7 +4428,7 @@ fn register_active_direct_codex_turn( /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; -/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +/// 这一轮已经没有人替它收尾,占用已被兜底解除:界面必须自己复位。 pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 @@ -4444,12 +4444,12 @@ pub(crate) struct DirectTurnCancelView { pub(crate) client_turn_id: String, } -/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +/// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 enum DirectCodexTurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 - Stale(DirectTaonierStaleGuardReason), + Stale(DirectStaleTurnReleaseReason), } /// 终止当前项目正在运行的 Direct 回合。 @@ -4459,13 +4459,13 @@ enum DirectCodexTurnCancelTarget { /// 不动任何既有事件或命令语义。 /// /// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 -/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 -/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 -/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 -/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 -/// 保持原拒绝语义,什么都不释放。 +/// 只发中断会让 Thread Manager 上这一轮的占用永远解不开,这个项目此后每条消息都会被 +/// "已有另一条回合正在跑"挡在放行之外——这正是"重进会话被堵死"的死锁形态。这时显式写一条 +/// `turn.completed{aborted}` 解除占用并把可读原因返回给界面。释放条件见 +/// [`direct_stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, +/// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 +/// 兜底终态带 `userItemId`:身份取 [`direct_stale_turn_for_release`] 返回的 /// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 /// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { @@ -4502,10 +4502,10 @@ pub(crate) fn cancel_direct_codex_turn_at( DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) } Ok(_) => { - DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + DirectCodexTurnCancelTarget::Stale(DirectStaleTurnReleaseReason::ExecutorExited) } Err(_) => DirectCodexTurnCancelTarget::Stale( - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ), } }; @@ -4523,17 +4523,17 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - let released = - release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + // 先做前置校验:确实登记着一轮、身份对得上、过了启动窗口才允许判成残留。 + let released = direct_stale_turn_for_release(root, client_turn_id, reason)?; // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 - // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的放行。 + // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。走 Thread Manager 的 + // 深层出口而不是裸 append:这也是**解除占用的唯一出口**,解除不了这个 thread 会一直 + // 被认为是"还有没收口的回合",挡住后面的放行。 complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); - // 这一轮不会再有人替它收尾(守卫刚被兜底释放),占用也在这里解除了:踢一脚让队列继续。 + // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_direct_queue_dispatch(root); Ok(DirectTurnCancelView { @@ -8048,9 +8048,6 @@ done }); let thread_id = direct_thread_id_for_project(&project); let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); let _reservation = crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); crate::agent::append_direct_project_user_message_at(&project, &user_item) @@ -8207,12 +8204,8 @@ done bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 - // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 - let _active_invocation = - crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") - .expect("enter direct invocation"); - // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行,再把用户条目落盘: + // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行(认领时登记调用身份), + // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = crate::agent::DirectTurnReservation::accept_for_test(&thread_id, "turn-0001"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 73a283b85..400202c54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -432,7 +432,7 @@ pub(super) async fn begin( let root = root.to_path_buf(); let prompt_hash = hash(prompt.as_bytes()); let session = tokio::task::spawn_blocking(move || { - let turn = super::direct_taonier_active_invocation_id_at(&root)?; + let turn = super::direct_active_turn_id_at(&root)?; let host = crate::game_creator_runtime_config_dir() .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; open_with_analytics_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index 971d41931..772166b30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -349,11 +349,7 @@ pub(super) async fn prefetch_turn_input( let scan_root = root.to_path_buf(); let expected_turn = client_turn_id.to_string(); let files = tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&scan_root) - .ok() - .as_deref() - != Some(expected_turn.as_str()) - { + if direct_active_turn_id_at(&scan_root).ok().as_deref() != Some(expected_turn.as_str()) { return Vec::new(); } let candidates = [ @@ -590,8 +586,7 @@ mod tests { #[tokio::test] async fn host_prefetch_keeps_data_out_of_system_rules_and_matches_active_turn() { let (_temp, root) = project(); - // 调用身份(预取闸门)与逻辑回合(上下文身份)是两件事,生产入口两步都做。 - let _guard = DirectTaonierActiveInvocationGuard::enter(&root, "prefetch-turn").unwrap(); + // 预取闸门与上下文身份读的是同一处:Thread Manager 上这一轮的活动回合登记。 let _turn = DirectTurnReservation::accept_for_test( &direct_thread_id_for_project(&root), "prefetch-turn", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 3fa5066b0..83040048a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1,11 +1,9 @@ use super::*; use base64::Engine as _; use std::collections::BTreeMap; -use std::collections::HashMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::Mutex; mod user_input; pub(crate) use user_input::enqueue_direct_codex_turn; @@ -453,100 +451,16 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String format!("{:x}", hasher.finalize()) } -#[derive(Debug)] -struct DirectTaonierActiveInvocation { - invocation_id: String, - started_at: u64, -} - -static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< - Mutex>, -> = OnceLock::new(); - -/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 -/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 -/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 -/// 不可能再进入执行器,守卫是残留。 -const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; - -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - -#[derive(Debug)] -pub(crate) struct DirectTaonierActiveInvocationGuard { - root: PathBuf, - invocation_id: String, -} - -impl DirectTaonierActiveInvocationGuard { - pub(crate) fn enter(root: &Path, invocation_id: &str) -> Result { - let root = root - .canonicalize() - .map_err(|error| DirectTurnError::ProjectRootUnanchored { - cause: error.to_string(), - })?; - let active = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get_or_init(|| Mutex::new(HashMap::new())); - let mut active = active - .lock() - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "Direct 调用身份锁已损坏".to_string(), - })?; - match active.get(&root) { - Some(existing) => { - return Err(DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: existing.invocation_id.clone(), - incoming_invocation_id: invocation_id.to_string(), - }); - } - None => { - let started_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - active.insert( - root.clone(), - DirectTaonierActiveInvocation { - invocation_id: invocation_id.to_string(), - started_at, - }, - ); - } - } - Ok(Self { - root, - invocation_id: invocation_id.to_string(), - }) - } -} - -impl Drop for DirectTaonierActiveInvocationGuard { - fn drop(&mut self) { - let Some(active) = DIRECT_TAONIER_ACTIVE_INVOCATIONS.get() else { - return; - }; - let Ok(mut active) = active.lock() else { - return; - }; - let remove = active - .get(&self.root) - .is_some_and(|existing| existing.invocation_id == self.invocation_id); - if remove { - active.remove(&self.root); - } - } -} - -pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| active.invocation_id.clone()) +/// 这个项目上正在跑的那一轮身份(`clientTurnId`),取自 **Thread Manager 的活动回合**。 +/// +/// 它是"这一轮是谁 / 在不在跑"的唯一登记:首页「运行中的项目」快照、放行闸门(有未收口回合就不认领 +/// 队首)读的都是这一处,不再另立第二份守卫表。 +/// +/// 拿不到身份时返回带前缀的错误:调用方(付费美术执行会话、工具桥、校验预约、上下文预取)都靠 +/// "拿不到身份"来拒绝"在没有回合的情况下创建 / 清理生成账本"。 +pub(crate) fn direct_active_turn_id_at(root: &Path) -> Result { + read_direct_turn_identity(&direct_thread_id_for_project(root)) + .map(|identity| identity.client_turn_id) .ok_or_else(|| { format!( "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 当前付费美术调用缺少稳定 clientTurnId,已拒绝创建或清理生成账本" @@ -554,49 +468,26 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏".to_string())? - .get(&root) - .map(|active| DirectActiveTurnView { - client_turn_id: active.invocation_id.clone(), - started_at: active.started_at, - })) +/// 上限用来挡"刚放行、还在本地准备(读配置 / manifest / 拼系统提示 / 开审计)的回合被终止误伤": +/// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 +const DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS: u64 = 60_000; + +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 } -/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +/// "终止"拿不到可中断句柄时的分类,决定这一轮能不能被兜底释放。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum DirectTaonierStaleGuardReason { +pub(crate) enum DirectStaleTurnReleaseReason { /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 ExecutorExited, /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 NeverReachedExecutor, } -impl DirectTaonierStaleGuardReason { +impl DirectStaleTurnReleaseReason { pub(crate) fn message(self) -> &'static str { match self { Self::ExecutorExited => "陶泥儿执行进程已退出", @@ -605,53 +496,42 @@ impl DirectTaonierStaleGuardReason { } } -/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// 兜底释放前的前置校验:这一轮是不是**可以**被判成残留,返回它的 `clientTurnId`。 /// -/// 释放条件(四条必须同时成立,这段注释就是契约): -/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 条件(三条必须同时成立,这段注释就是契约): +/// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); /// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; -/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; -/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 -/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" -/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 +/// [`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; -/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 -pub(crate) fn release_stale_direct_taonier_active_invocation( +/// **释放本身不在这里**:调用方拿到身份后往 Thread Manager 写一条 `turn.completed{aborted}`, +/// 那才是解除占用的唯一出口(也因此天然幂等——占用已经没了就返回"没有正在运行的回合")。 +pub(crate) fn direct_stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, - reason: DirectTaonierStaleGuardReason, + reason: DirectStaleTurnReleaseReason, ) -> Result { - let root = root - .canonicalize() - .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; - let Some(existing) = active.get(&root) else { - return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); - }; + let thread_id = direct_thread_id_for_project(root); + let active = read_direct_turn_identity(&thread_id) + .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; if let Some(expected) = expected_client_turn_id .map(str::trim) .filter(|value| !value.is_empty()) { - if existing.invocation_id != expected { + if active.client_turn_id != expected { return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); } } - if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); - if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { + if reason == DirectStaleTurnReleaseReason::NeverReachedExecutor { + let age_ms = direct_taonier_active_now_millis().saturating_sub(active.started_at); + if age_ms < DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS { return Err(format!( "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", age_ms / 1000 )); } } - let released = existing.invocation_id.clone(); - active.remove(&root); - Ok(released) + Ok(active.client_turn_id) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -3262,7 +3142,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( )); } let mut regeneration_workflow = if mode.regenerates_existing() { - let invocation_id = direct_taonier_active_invocation_id_at(root)?; + let invocation_id = direct_active_turn_id_at(root)?; Some(prepare_direct_taonier_regeneration_workflow_at( root, prompt, @@ -5639,170 +5519,93 @@ mod tests { assert!(normalize_direct_client_turn_id(None).is_err()); } + /// "这一轮是谁"只有 Thread Manager 一处真相;读取是只读的,不改占用。 #[test] - fn active_client_turn_id_is_exclusive_until_the_outer_turn_finishes() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("first client turn"); - let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect_err("same stable turn is already running"); - assert!( - matches!( - &duplicate, - DirectTurnError::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } if existing_invocation_id == "client-turn-0001" - && incoming_invocation_id == "client-turn-0001" - ), - "{duplicate:?}" - ); - // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 - assert_eq!( - duplicate.to_string(), - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" - ); - let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") - .expect_err("different turn cannot take over the project"); - assert!( - different - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行"), - "{different}" - ); - drop(first); - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0001") - .expect("lost-response replay after the original turn finishes"); - } + fn reading_the_active_turn_identity_does_not_take_over_the_occupancy() { + let root = tempfile::tempdir().expect("active turn root"); + let thread_id = direct_thread_id_for_project(root.path()); + assert_eq!(read_direct_turn_identity(&thread_id), None); - #[test] - fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { - let root = tempfile::tempdir().expect("active invocation root"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") - .expect("first client turn"); - let running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("running turn is visible to the read-only probe"); + let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-read-1"); + let running = read_direct_turn_identity(&thread_id).expect("running turn is readable"); assert_eq!(running.client_turn_id, "client-turn-read-1"); assert!(running.started_at > 0, "{running:?}"); - // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 assert_eq!( - serde_json::to_value(&running).expect("serialize view"), - serde_json::json!({ - "clientTurnId": "client-turn-read-1", - "startedAt": running.started_at, - }) + direct_active_turn_id_at(root.path()).expect("identity for the running turn"), + "client-turn-read-1" ); - // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 - let duplicate = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") - .expect_err("read-only probe must not take over the project"); - assert!(duplicate - .to_string() - .contains("已有另一条 Direct 客户端回合正在运行")); + // 只读探测不释放占用:认领仍然被这一轮挡着。 + assert!(claim_direct_pending_turn(&thread_id).is_none()); - drop(first); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); + drop(reservation); + assert_eq!(read_direct_turn_identity(&thread_id), None); + assert!(direct_active_turn_id_at(root.path()).is_err()); } + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口。 #[test] - fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") - .expect("first client turn"); + fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("stale turn root"); + let thread_id = direct_thread_id_for_project(root.path()); + let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 - let mismatch = release_stale_direct_taonier_active_invocation( + let mismatch = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-2"), - DirectTaonierStaleGuardReason::ExecutorExited, + DirectStaleTurnReleaseReason::ExecutorExited, ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert!( - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() + assert_eq!( + read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) ); - // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 - let young = release_stale_direct_taonier_active_invocation( + // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ) - .expect_err("a freshly registered turn is still starting"); + .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 - backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); - let released = release_stale_direct_taonier_active_invocation( + // ③ 老过窗口:判成残留,调用方拿到身份(解除占用由调用方写终态完成)。 + backdate_direct_active_turn_for_test(&thread_id, DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS + 1); + let released = direct_stale_turn_for_release( root.path(), Some("client-turn-stale-1"), - DirectTaonierStaleGuardReason::NeverReachedExecutor, + DirectStaleTurnReleaseReason::NeverReachedExecutor, ) - .expect("stale guard is released"); + .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") - .expect("a new turn can start once the stale guard is released"); - // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 - drop(first); - let still_running = read_direct_taonier_active_invocation_at(root.path()) - .expect("read running project") - .expect("the newer turn survives the stale guard drop"); - assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); - drop(second); + // ④ "执行进程已退出"不看启动窗口:刚放行也允许释放。 + assert_eq!( + direct_stale_turn_for_release( + root.path(), + None, + DirectStaleTurnReleaseReason::ExecutorExited, + ) + .expect("executor exited ignores the start window"), + "client-turn-stale-1" + ); + drop(reservation); } + /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 #[test] - fn stale_guard_release_after_the_executor_exited_frees_the_project() { - let root = tempfile::tempdir().expect("active invocation root"); - let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") - .expect("client turn"); - let released = release_stale_direct_taonier_active_invocation( + fn stale_turn_release_without_an_active_turn_says_so() { + let root = tempfile::tempdir().expect("empty turn root"); + let nothing = direct_stale_turn_for_release( root.path(), None, - DirectTaonierStaleGuardReason::ExecutorExited, - ) - .expect("executor exited: this guard is residue"); - assert_eq!(released, "client-turn-exited-1"); - assert_eq!( - read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), - None - ); - let _second = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") - .expect("a new turn can start after the residue is released"); - drop(first); - - // 没有任何登记时给出可读原因,而不是静默成功。 - let empty = tempfile::tempdir().expect("empty invocation root"); - let nothing = release_stale_direct_taonier_active_invocation( - empty.path(), - None, - DirectTaonierStaleGuardReason::ExecutorExited, + DirectStaleTurnReleaseReason::ExecutorExited, ) .expect_err("nothing to release"); assert!(nothing.contains("没有正在运行"), "{nothing}"); } - /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 - fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { - let root = root.canonicalize().expect("canonical root"); - let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .expect("active invocation lock"); - let entry = active.get_mut(&root).expect("registered invocation"); - entry.started_at = entry.started_at.saturating_sub(age_ms); - } - #[test] fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() { let root = tempfile::tempdir().expect("temp dir"); @@ -6780,9 +6583,10 @@ mod tests { .expect("completed replay authority is the stable outer turn, not a resampled brief"); assert_eq!(completed_replay, persisted); - let active = - DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-completed-1") - .expect("restore the same client invocation after response loss"); + let _active_turn = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root.path()), + "client-turn-completed-1", + ); let replay = ensure_direct_taonier_art_package_at( root.path(), "第一套美术但偷偷换 brief", @@ -6799,7 +6603,6 @@ mod tests { .state, DirectTaonierRegenerationWorkflowState::Completed ); - drop(active); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index f3b844034..ea7dfb10d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -83,6 +83,19 @@ pub(crate) struct DirectActiveTurnSnapshot { pub(crate) sequence: u64, } +/// 一个 thread 上正在跑的那一轮的**只读身份**:给"这一轮是谁"的读者用(付费美术执行会话、 +/// 工具桥、校验预约、上下文预取)。 +/// +/// 身份取自 Thread Manager 的活动回合登记——"这一轮在不在跑"只有这一处真相,界面快照 +/// ([`list_direct_active_turns`])与放行闸门读的也是它,不再另立第二份记录。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DirectTurnIdentity { + /// 与事件 `turnId` 同一个身份(`clientTurnId`)。 + pub(crate) client_turn_id: String, + /// 这一轮被放行(登记占用)的时刻(Unix 毫秒)。 + pub(crate) started_at: u64, +} + /// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 /// /// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 @@ -314,6 +327,19 @@ impl DirectThreadManager { true } + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 + fn active_turn_identity(&self, thread_id: &str) -> Option { + self.threads.get(thread_id).and_then(|thread| { + thread + .active_turn + .as_ref() + .map(|active| DirectTurnIdentity { + client_turn_id: active.turn_id.clone(), + started_at: active.started_at, + }) + }) + } + fn turn_is_active(&self, thread_id: &str) -> bool { self.threads .get(thread_id) @@ -836,6 +862,16 @@ pub(crate) fn complete_direct_thread_turn_if_reserved( written } +/// 这个 thread 上正在跑的那一轮身份。 +/// +/// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 +pub(crate) fn read_direct_turn_identity(thread_id: &str) -> Option { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .active_turn_identity(thread_id) +} + /// 这个 thread 是否还有没收口的逻辑回合。 pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { global_direct_thread_manager() @@ -844,6 +880,24 @@ pub(crate) fn direct_thread_turn_is_active(thread_id: &str) -> bool { .turn_is_active(thread_id) } +/// 测试用:把某 thread 上正在跑那一轮的登记时刻往前拨 `age_ms`。 +/// +/// 只服务"启动窗口闸门"这一条判据:终止的兜底释放只对"登记已存在很久"的残留放行, +/// 真实用例等不了 60 秒。 +#[cfg(test)] +pub(crate) fn backdate_direct_active_turn_for_test(thread_id: &str, age_ms: u64) { + let mut manager = global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(active) = manager + .threads + .get_mut(thread_id) + .and_then(|thread| thread.active_turn.as_mut()) + { + active.started_at = active.started_at.saturating_sub(age_ms); + } +} + fn notify_direct_thread_subscribers(thread_id: &str) { let subscriber_ids = { let manager = global_direct_thread_manager() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 93b0d5741..18ebdac42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -267,7 +267,7 @@ impl DirectToolBridge { impl DirectToolBridgeState { fn begin_user_turn(self: &Arc) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; + let turn_id = direct_active_turn_id_at(&self.root)?; let mut authorization = self .turn_authorization .lock() @@ -4665,9 +4665,10 @@ mod tests { let state = direct_tool_bridge_state(root.path().to_path_buf()); assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; - let _active_invocation = - DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) - .expect("client-owned stable invocation"); + let _active_invocation = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root.path()), + client_turn_id, + ); let active_turn = state .begin_user_turn() .expect("client turn authorization state"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28c506608..e89980666 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -3838,19 +3838,21 @@ mod tests { (handle, receiver) } - /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + /// 真实项目 + 真实工具桥 + 真实 Direct 回合占用(这一轮的调用身份)。 async fn tool_chain_start( root: &Path, ) -> ( super::super::direct_tool_bridge::DirectToolBridge, - DirectTaonierActiveInvocationGuard, + DirectTurnReservation, super::super::direct_tool_bridge::DirectExecutionTestFixture, ) { let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) .await .expect("start tool chain bridge"); - let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") - .expect("arm tool chain direct turn"); + let turn = DirectTurnReservation::accept_for_test( + &direct_thread_id_for_project(root), + "tool-chain-turn", + ); let execution = super::super::direct_tool_bridge::direct_execution_fixture(root, "tool-chain-turn") .await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs index 689298c53..734c5b852 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_dispatch.rs @@ -2,7 +2,10 @@ //! //! 这个模块两件事,别再往里加第三件: //! 1. [`DirectTurnReservation`]:这一轮的占用对象——持有它代表还没收口,终态出口只认它的 token; -//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(取调用身份 → 落盘用户条目 → 下发 → 跑回合)。 +//! 2. [`kick_direct_queue_dispatch`]:认领队首 → 起整轮(落盘用户条目 → 下发 → 跑回合)。 +//! +//! 这一轮的**调用身份**(付费美术、执行会话、MCP、校验、上下文预取读的那个"这一轮是谁")由认领 +//! 在 Thread Manager 上登记,认领成功即成立;这里不再另取一次、也不再持第二份身份。 //! //! 放行**不重跑任何入队检查,也没有"放行失败"**:入队时的检查已经判过,这一轮一旦放行,之后的 //! 一切失败都是**回合失败**,由占用对象收口成 `turn.completed` 的失败载荷。设计见 @@ -14,8 +17,8 @@ use super::{ append_direct_project_user_message_at, claim_direct_pending_turn, complete_direct_thread_turn_if_reserved, direct_thread_id_for_project, direct_tool_call_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, - DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTaonierActiveInvocationGuard, - DirectTurnError, DirectTurnTerminal, PendingDirectTurn, + DirectDispatchedTurn, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, + PendingDirectTurn, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -88,7 +91,7 @@ impl Drop for DirectTurnReservation { /// /// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 /// **占用释放**(正常 / 失败 / panic 共用,见 [`DirectTurnReservation`] 的 `Drop`)、**中止路径** -/// (守卫被兜底释放时那一轮不会再有人替它收尾)。 +/// (兜底释放时那一轮不会再有人替它收尾)。 /// /// 它什么都不返回:没有"放行失败"。踢不动就是现在不该跑——已经有回合在跑,或者队列是空的; /// 这两种情况都会在下一次释放时再踢。 @@ -114,15 +117,6 @@ async fn run_dispatched_direct_turn( reservation: DirectTurnReservation, ) { let turn_id = dispatched.pending.client_turn_id.clone(); - // 调用身份整轮持有:付费美术、执行会话、MCP、校验、上下文预取都读它。这里拿不到身份说明这个 - // 项目上还有另一条调用没放(残留守卫 / 别处的入口),按回合失败说清原因;队列照常继续。 - let active_invocation = match DirectTaonierActiveInvocationGuard::enter(&root, &turn_id) { - Ok(guard) => guard, - Err(failure) => { - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); - return; - } - }; // 落盘即回合成立:放行之后必须留下这条用户消息,哪怕这一轮随后失败。 if let Err(error) = append_direct_project_user_message_at(&root, &dispatched.pending.canonical_user_item) @@ -168,9 +162,6 @@ async fn run_dispatched_direct_turn( reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); } } - // 调用身份先放,再让占用对象的 `Drop` 收尾并踢下一脚:顺序反过来就会出现"守卫还握着、队首却已经 - // 可以放行"的窗口——那一脚会被自己的守卫挡成一条假的回合失败。 - drop(active_invocation); } #[cfg(test)] @@ -354,4 +345,45 @@ mod tests { assert!(direct_thread_turn_is_active(&busy)); drop(reservation); } + + /// 前置判据:删掉调用身份守卫之后,"回合任务泄漏、app-server 侧没有可中断句柄"的兜底释放 + /// 仍要能解开占用并把队首放行出去——`d833ca9d3` 的"重进会话被堵死"不得复活。 + #[test] + fn stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn() { + let thread = unique_thread("stale-cancel"); + let subscription = watch(&thread); + // 这一轮已经被放行(占用登记在 Thread Manager 上),但任务泄漏:拿不到可中断句柄。 + let leaked = DirectTurnReservation::accept_for_test(&thread, "turn-stale-1"); + enqueue(&thread, "turn-stale-2"); + + // "从没进执行器"只对过了启动窗口的残留放行;真实用例等不了 60 秒。 + crate::agent::backdate_direct_active_turn_for_test(&thread, 61_000); + let view = + crate::agent::cancel_direct_codex_turn_at(Path::new(&thread), Some("turn-stale-1")) + .expect("stale release"); + assert_eq!( + view.outcome, + super::super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_RELEASED + ); + assert_eq!(view.client_turn_id, "turn-stale-1"); + + // 占用解开:第一条被兜底收口,第二条接上——终止这一脚就是"继续放行"。 + let events = pending(&subscription); + assert!( + events.iter().any(|event| matches!( + event, + DirectThreadEvent::TurnCompleted { status, .. } if status == "aborted" + )), + "{events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + DirectThreadEvent::TurnStarted { user_item_id, .. } + if user_item_id.as_deref() == Some("direct-codex:turn-stale-2:user") + )), + "队首必须在兜底释放之后被放行:{events:?}" + ); + drop(leaked); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 33b849435..2cffc419b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -368,10 +368,10 @@ async fn start_for_current_turn( key: String, build: bool, ) -> Result { - let turn_id = direct_taonier_active_invocation_id_at(root)?; + let turn_id = direct_active_turn_id_at(root)?; let root = root.to_path_buf(); tokio::task::spawn_blocking(move || { - if direct_taonier_active_invocation_id_at(&root)? != turn_id { + if direct_active_turn_id_at(&root)? != turn_id { return Err("validation-turn-changed: 当前验证所属回合已结束".into()); } let session = super::direct_execution::current(&root)?; From 4e8b269816a75ee1608d790a054f5f12473e4e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:39:53 +0800 Subject: [PATCH 09/28] =?UTF-8?q?=E5=88=A0=E6=8E=89=20TurnAlreadyRunning?= =?UTF-8?q?=EF=BC=9A=E5=85=A5=E9=98=9F=E5=8C=96=E5=90=8E=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=92=9E=E5=8D=95=E7=9A=84=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删 DirectTurnError::TurnAlreadyRunning 变体,以及 is_reportable / Display 的对应分支与专属用例 - 前端 directTurnEnqueueFailureNotice 删掉 turnAlreadyRunning 分支;ts-rs 绑定同步删除该变体 - 结构化载荷用例的样本换成 queueFull,本地说明用例的文案样本换成队列已满提示 --- .../src-tauri/src/agent/direct_turn_error.rs | 50 ------------------- .../conversation/directCodexConversation.ts | 1 - .../chat/generated/DirectTurnError.ts | 5 -- .../appSurface/project-conversation.suite.ts | 18 +++---- .../tests/directTurnPresentation.test.ts | 4 +- 5 files changed, 9 insertions(+), 69 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 774fa34aa..80144ee12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -390,13 +390,6 @@ pub(crate) enum DirectTurnError { ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, - /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 - /// - /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 - TurnAlreadyRunning { - existing_invocation_id: String, - incoming_invocation_id: String, - }, /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 /// /// 上限只落在宿主这一处(`MAX_PENDING_DIRECT_TURNS`),随载荷带出去,界面不自己数一份。 @@ -487,7 +480,6 @@ impl DirectTurnError { Self::ClientTurnIdMissing | Self::ClientTurnIdMalformed { .. } | Self::QueueFull { .. } - | Self::TurnAlreadyRunning { .. } // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 | Self::ProjectRootUnanchored { .. } @@ -653,23 +645,6 @@ impl fmt::Display for DirectTurnError { formatter, "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" ), - Self::TurnAlreadyRunning { - existing_invocation_id, - incoming_invocation_id, - } => { - // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 - // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 - if existing_invocation_id == incoming_invocation_id { - formatter.write_str( - "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", - ) - } else { - write!( - formatter, - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" - ) - } - } Self::ProjectRootUnanchored { cause } => { write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") } @@ -1022,11 +997,6 @@ mod tests { policy_detail: "项目权限策略拒绝执行:conversation.write".into(), } .is_reportable()); - assert!(!DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - } - .is_reportable()); // 回合级失败在上游已经写过诊断。 assert!( !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") @@ -1034,26 +1004,6 @@ mod tests { ); } - /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 - #[test] - fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { - let same = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-1".into(), - }; - assert!(same.to_string().contains("同一轮消息仍在处理中")); - let different = DirectTurnError::TurnAlreadyRunning { - existing_invocation_id: "turn-1".into(), - incoming_invocation_id: "turn-2".into(), - }; - assert!(different - .to_string() - .contains("另一条 Direct 客户端回合正在运行")); - assert!(!different - .to_string() - .starts_with("direct-codex-turn-already-running:")); - } - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 #[test] fn boundary_serialization_uses_display() { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 62d80f344..bb1231450 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -102,7 +102,6 @@ export function directTurnEnqueueFailureNotice( case 'clientTurnIdMissing': case 'clientTurnIdMalformed': case 'queueFull': - case 'turnAlreadyRunning': case 'projectRootUnanchored': case 'projectRootUnusable': case 'permissionRejected': diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts index 94d69d66a..22c445d40 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -9,11 +9,6 @@ import type { DirectTurnDeadline } from './DirectTurnDeadline'; export type DirectTurnError = | { type: 'clientTurnIdMissing' } | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } - | { - type: 'turnAlreadyRunning'; - existingInvocationId: string; - incomingInvocationId: string; - } | { type: 'queueFull'; limit: number } | { type: 'projectRootUnanchored'; cause: string } | { type: 'projectRootUnusable' } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 45745ed82..18ce02538 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -23,19 +23,15 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 - const concurrent = { - error: { - type: 'turnAlreadyRunning' as const, - existingInvocationId: 'turn-1', - incomingInvocationId: 'turn-1', - }, - message: '同一轮消息仍在处理中', + const queueFull = { + error: { type: 'queueFull' as const, limit: 5 }, + message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', }; - expect(readDirectTurnEnqueueFailure(concurrent)?.error.type).toBe( - 'turnAlreadyRunning', + expect(readDirectTurnEnqueueFailure(queueFull)?.error.type).toBe( + 'queueFull', ); - expect(directTurnEnqueueFailureNotice(concurrent)).toBe( - '同一轮消息仍在处理中', + expect(directTurnEnqueueFailureNotice(queueFull)).toBe( + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ); // 认得的参数 / 前置条件类都给同级提示。 expect( diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 06ce3bd38..7dbe863b7 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -356,7 +356,7 @@ describe('DirectProject 聊天分区', () => { entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')], localMessages: [ localNotice( - '同一轮消息仍在处理中', + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', 'direct-codex:turn-9:user:rejected', ), ], @@ -370,7 +370,7 @@ describe('DirectProject 聊天分区', () => { expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); expect(turns[1]?.finals.map((block) => block.text)).toEqual([ - '同一轮消息仍在处理中', + '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ]); }); From 354acfd70b6ba0addf6aa166e25fb3048bf73909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 10:41:10 +0800 Subject: [PATCH 10/28] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject?= =?UTF-8?q?=20=E5=AE=88=E5=8D=AB=E6=B8=85=E7=90=86=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=EF=BC=8CADR=20=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 状态改为 2026-09-30 落地完成,§9 守卫退役条目改成已落地口径 - 实施计划状态改为第 0–5 步全部完成,进度表第 5 步写成落地说明;第 4 步的 TurnAlreadyRunning 交还给第 5 步 - 09-23 接单化计划加后续注记:接单/拒单词表与 TurnAlreadyRunning 已退役,正文不再回改 - decision-log 的 09-30 条目补落地与验证结果,09-24 条目加落地指向 --- ...ct命令入队化与待发消息队列归宿主-2026-09-24.md | 5 +++-- .../shared-memory/decision-log.md | 17 +++++++++++------ ...roject命令入队化与待发消息队列归宿主-2026-09-24.md | 8 ++++---- ...ž施计划】DirectProject命令接单化-2026-09-23.md | 3 +++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 90d8b2735..569887ae5 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -1,6 +1,6 @@ # 【ADR】DirectProject命令入队化与待发消息队列归宿主 -状态:已接受(2026-09-24 设计定稿;**尚未落地**,实施顺序与验收见 +状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**,实施顺序与验收见 [`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) ## 背景 @@ -87,7 +87,8 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 - `DirectTaonierActiveInvocationGuard` 的**身份**与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录 (GUI 路径下同源字符串),而 09-23 ADR 立的是"同一件事只许有一处真相"。CLI 退役后它的硬阻塞消失:五个读者 (`direct_execution` / `direct_tool_bridge` / `direct_validation` / `direct_project_context` / 付费美术重生成) - 改读 Thread Manager 的活动回合身份,守卫连同它的测试一起删,不再有第二份进程内记录。 + 已改读 Thread Manager 的活动回合身份(`direct_active_turn_id_at`),守卫连同它的测试一起删掉,不再有第二份 + 进程内记录。 - 删守卫的前置判据**已核实(2026-09-30)**,不需要新增"取消路径补无条件终态":`d833ca9d3` 的兜底路径本来就 同时做三件事——释放占用、往事件流补一条 `turn.completed{aborted}`(否则前端会永远停在运行中)、调 `complete_direct_thread_turn` 解除占用。删掉的只有 `DirectTaonierActiveInvocationGuard` 那张进程内表; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 66dc906c3..118f06d08 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9609,6 +9609,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `DirectTaonierActiveInvocationGuard` 的身份与 Thread Manager 的 `active_turn.turn_id` 是同一件事的两份记录, CLI 退役后让那五个读者改读 Thread Manager,再在第二步删掉守卫与它的 60 秒卡死兜底 (删前必须保住"回合卡死可被取消解开"这条由 `d833ca9d3` 事故换来的保证)。 + **(落地,2026-09-30)**:见下方 09-30 条,守卫已删、`TurnAlreadyRunning` 已删,60 秒闸门改挂 + Thread Manager 的占用登记年龄。 - 代价:入队即写盘(引用 UI 设计文档的条目会生成 `ui/generated-.js`,取消不撤);检查是时间点事实、 放行不重跑(manifest / 权限 / 目录变化后照旧放行,偏差落到回合失败);队列随进程消失,不做跨进程持久化; 失去"真实二进制驱动执行层生产验证"这条手工路径。 @@ -9629,9 +9631,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `NeverReachedExecutor` 时校验年龄(`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`),返回身份;释放本身仍由 既有的 `complete_direct_thread_turn` + `kick_direct_queue_dispatch` 完成(天然幂等)。 - 决策:连接死亡的失败事实由 app-server 连接层自己落地(`ab970b9fd`),与守卫无关,删除守卫不动那条链路。 -- 影响范围:`agent/direct_runtime/mod.rs`(删守卫表 / 只读探测,改为 `direct_active_turn_id_at`)、 - `agent/direct_thread_manager.rs`(`DirectTurnIdentity` + `read_direct_turn_identity`)、 - `agent/codex_app_server/mod.rs`(兜底目标改 `DirectStaleTurnReleaseReason`)、五个身份读者与相关测试、 - 删 `DirectTurnError::TurnAlreadyRunning` 与前端对应分支 / 生成绑定。 -- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`;`npx vitest run tests/appSurface.test.ts`; - `npm --workspace apps/ai-game-creator-shell run typecheck`;`npm run check:encoding`;`git diff --check`。 +- 落地(`direct_runtime/mod.rs` 删守卫表与只读探测,改 `direct_active_turn_id_at`;`direct_thread_manager.rs` + 补 `DirectTurnIdentity` + `read_direct_turn_identity`;`codex_app_server/mod.rs` 兜底目标改 + `DirectStaleTurnReleaseReason` 并接 `direct_stale_turn_for_release`;五个身份读者与相关测试改写; + `direct_turn_dispatch.rs` 不再另取调用身份;删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定)。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell -- agent::direct`(370 passed);新增 + `stale_cancel_releases_the_occupancy_and_dispatches_the_next_pending_turn` 与两条 `direct_stale_turn_for_release` + 单测;`npx vitest run tests/appSurface.test.ts`(214 passed / 9 skipped)、`directThreadChat` / + `directTurnPresentation` / `project-conversation` 定向用例、`npm --workspace apps/ai-game-creator-shell run typecheck`、 + `npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 8a7dc67f6..ff038a336 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,7 +2,7 @@ 更新时间:`2026-09-24` -状态:**实施中**(第 0–4 步已落地;第 5 步进行中) +状态:**已落地**(第 0–5 步全部完成,2026-09-30) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -16,7 +16,7 @@ | 第 2 步 队列归 Thread Manager | 已落地 | `StoredEvent.pending` 产物字段 + `enqueue_pending_turn` / `remove_pending_turn` / `claim_pending_turn`;放行在 `agent/direct_turn_dispatch.rs`(`kick_direct_queue_dispatch` + `DirectTurnReservation`) | | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | -| 第 5 步 守卫清理 | 进行中 | 前置判据已核实;改读 Thread Manager 活动回合身份、删调用身份守卫与其测试,并删 `TurnAlreadyRunning` | +| 第 5 步 守卫清理 | 已落地 | 五个身份读者改读 `direct_active_turn_id_at`;删 `DirectTaonierActiveInvocationGuard` / `DirectActiveTurnView` / 只读探测与 `release_stale_direct_taonier_active_invocation`(改 `direct_stale_turn_for_release` 只做前置校验);删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定;补前置判据用例 | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -145,8 +145,8 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - `agent/direct_runtime/mod.rs`:删 `run_direct_game_creator_turn_at` 与 `run_direct_game_creator_turn_at_with_creation_type` (各自只有彼此与 CLI 一个调用方)。 - 删 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(CLI 的唯一消费者)。 -- 删 `DirectTurnError::TurnAlreadyRunning`(两个生产点都没了)与前端"同一轮消息仍在处理中"文案、专属分支、 - `tests/appSurface/project-conversation.suite.ts` 的对应断言。 +- `DirectTurnError::TurnAlreadyRunning` 与前端"同一轮消息仍在处理中"文案、专属分支、`project-conversation.suite.ts` + 的对应断言**挪到第 5 步**(它最后一个生产点在调用身份守卫里)。 - 文档同步:09-22 里程碑把 `--direct-codex-chat` 从"范围外(保留)"改成退役项;两份 Direct 技术方案的 CLI 承诺删掉; 09-23 ADR 与 `decision-log.md` 里"CLI 保持 await"的口径改掉。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 4724c1822..989095efb 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -4,6 +4,9 @@ 状态:**四步全部落地**。 +后续:09-24 起命令边界改叫「入队 / 入队失败 / 放行」,本文里的「接单 / 拒单」与 `TurnAlreadyRunning` +都已退役(见 09-24 ADR 与 09-30 决策记录);正文保留当时口径,不再回改。 + 设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 From 3e8954548ac7b51234b42f9182b856486c356bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:33:42 +0800 Subject: [PATCH 11/28] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9B=BF=E8=BA=AB?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=AE=BF=E4=B8=BB=E7=9A=84=E6=8C=89=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=88=A4=E5=AE=9A=EF=BC=9AalreadyDispatched=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E6=AD=A3=E5=9C=A8=E8=B7=91=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hostQueue 记录被放行的 clientTurnId,cancel 只在身份一致时回 alreadyDispatched,其余回 notFound - 与宿主 remove_pending_turn(按 active_turn.turn_id 比对)保持一致,避免复用该替身时掩盖 notFound 分支 --- .../tests/appSurface/chat-composer.suite.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 132940ace..3e6545351 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -244,11 +244,14 @@ function hostQueue( ) { const queued: Array<{ clientTurnId: string; userItem: unknown }> = []; let running = alreadyRunning; + /** 正在跑那一轮的回合身份:与宿主一致,`alreadyDispatched` 只认它(不是"随便哪一轮在跑")。 */ + let dispatchedClientTurnId: string | null = null; const dispatchHeadIfIdle = () => { if (running) return; const head = queued.shift(); if (!head) return; running = true; + dispatchedClientTurnId = head.clientTurnId; harness.emitDirectThreadEvents( { type: 'queue.removed', @@ -283,7 +286,11 @@ function hostQueue( const index = queued.findIndex( (item) => item.clientTurnId === clientTurnId, ); - if (index < 0) return running ? 'alreadyDispatched' : 'notFound'; + if (index < 0) { + return dispatchedClientTurnId === clientTurnId + ? 'alreadyDispatched' + : 'notFound'; + } queued.splice(index, 1); harness.emitDirectThreadEvents({ type: 'queue.removed', @@ -296,6 +303,7 @@ function hostQueue( /** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */ complete(status = 'completed') { running = false; + dispatchedClientTurnId = null; harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 }); dispatchHeadIfIdle(); }, From f4558475a0fe59a83b36bdc1bc689c74c892047b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:42:57 +0800 Subject: [PATCH 12/28] =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=97=B6=E6=B8=85=E6=8E=89=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=9F=8B=E7=82=B9=E5=8F=A5=E6=9F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 控制器在 `remove_direct_project_pending_turn` 返回 `removed` 时删除该 `clientTurnId` 的埋点句柄 - 被取消的待发消息永远不会放行、也就不会有自己的 `turn.completed`,句柄留着只会泄漏 - 留着还会挤进"身份缺失"的兜底结算候选(挑认领表里最早那一条),把下一次结算算到没跑过的回合头上 - 补一条 appSurface 用例钉住这条不变式:回放没有身份的失败终态时,兜底结算只落到还能收口的那一条 --- .../useDirectProjectChatController.ts | 6 ++ .../tests/appSurface/chat-composer.suite.ts | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 429c71343..91f86394e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -335,6 +335,12 @@ export function useDirectProjectChatController({ { projectPath: nextProjectPath, clientTurnId }, ); if (projectPathRef.current !== nextProjectPath) return; + if (outcome === 'removed') { + // 这一条不会再被放行,也就不会再有 `turn.completed` 来结算它:句柄只能在这里清掉。 + // 留着不但泄漏,还会挤进"身份缺失"的兜底结算的候选里(`settlePendingRunAnalytics` + // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 + pendingRunAnalyticsRef.current.delete(clientTurnId); + } setComposerNotice( outcome === 'notFound' ? '这条待发消息已经不在队列里' diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3e6545351..5f653440e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -647,6 +647,61 @@ export function registerChatComposerControlTests() { ).toEqual(['第一条消息', '第二条消息', '第三条消息']); }); + it('不再认领被取消那一条的埋点句柄:身份缺失的兜底结算只落到还能收口的那一条', async () => { + // 取消掉的待发消息永远不会被放行,也就永远不会有它自己的 `turn.completed`:句柄必须在取消时清掉。 + // 留着的话,"身份缺失"的兜底结算(认领表里**最早**那一条)会先认出它——取消一条消息等于把下一次 + // 结算算到一条根本没跑过的回合头上,真正跑着的那条反而没人结算。 + let host: ReturnType | null = null; + const { invoke, surface, harness } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), + }); + // 线程上已有一条(恢复出来的)回合在跑:发出去的两条都只会排队,事件流里也不会走 `turn.started`。 + host = hostQueue(harness, { running: true }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await setComposerText(composer, '会被取消的消息'); + submitComposerForm(composer); + await setComposerText(composer, '留下来的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(2); + }); + + fireEvent.click( + within(queue).getByRole('button', { + name: '取消待发消息 会被取消的消息', + }), + ); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(1); + }); + const attempts = invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => String(args?.analyticsAttemptId ?? '')); + expect(attempts).toHaveLength(2); + const settledAttemptIds = () => + invoke.mock.calls + .filter(([command]) => command === 'settle_direct_run_analytics') + .map(([, args]) => String(args?.attemptId ?? '')); + + // 回放一条**没有身份**的失败终态:订阅重建时的生命周期锚点就是这个形状(订阅在新回合开始前 + // 断过,开局那条 `turn.started` 不在手里)。兜底结算此刻只能按"认领表里最早那一条"认人—— + // 被取消的那条若还留在表里,就会顶替掉真正该结算的那一条。 + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'failed', + failure: { message: '重连之前那一轮已经失败' }, + at: 4, + }); + }); + await waitFor(() => { + expect(settledAttemptIds()).toHaveLength(1); + }); + expect(settledAttemptIds()).toEqual([attempts[1]]); + }); + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 const { invoke, surface, harness } = await openDirectCodexSurface({ From 20126ead0c3ea5ff86a1dc8cd211a4ebc6e635aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:43:21 +0800 Subject: [PATCH 13/28] =?UTF-8?q?=E6=B3=A8=E9=87=8A=E7=94=A8=E8=AF=8D?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=EF=BC=9A=E5=85=A5=E9=98=9F=E5=8D=8A=20/=20?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E5=8D=8A=20=E6=94=B9=E6=88=90=20=E5=85=A5?= =?UTF-8?q?=E9=98=9F=E4=BE=A7=20/=20=E6=94=BE=E8=A1=8C=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `codex_app_server/mod.rs` 两处、`direct_runtime/mod.rs`、`direct_thread_queue.rs`、`direct_runtime/user_input.rs` 各处的简体表述统一 - 同步实施计划文档里的同一处措辞,保持词汇一致 - 只改措辞,不改任何行为 --- .../src-tauri/src/agent/codex_app_server/mod.rs | 4 ++-- .../src-tauri/src/agent/direct_runtime/mod.rs | 2 +- .../src-tauri/src/agent/direct_runtime/user_input.rs | 4 ++-- .../src-tauri/src/agent/direct_thread_queue.rs | 2 +- ...¡划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 4af07737d..2c3ada2fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3300,7 +3300,7 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); - // 用户条目由放行半在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; + // 用户条目由放行侧在**放行之后、起 codex 之前**落盘("落盘即回合成立"),这里不再重复写; // 本函数只取它的身份,把它作为本回合的第一条运行态条目下发,前端就能用同一个 itemId 把 // "本地乐观气泡"和"历史里的同一条"合成一条。 // 没有条目但给了 `clientTurnId` 的调用方(不是 GUI 命令那条路)只拿到一份本地投影: @@ -8204,7 +8204,7 @@ done bootstrap.events.is_empty(), "订阅发生在回合之前,bootstrap 必须为空" ); - // 生产入口(`enqueue_direct_codex_turn` + 放行半)在起 codex 之前先放行(认领时登记调用身份), + // 生产入口(`enqueue_direct_codex_turn` + 放行侧)在起 codex 之前先放行(认领时登记调用身份), // 再把用户条目落盘: // 这里补上同一步,于是这一轮的边界仍在同一个订阅里成对出现,历史里也有那条用户消息。 let _reservation = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 83040048a..964593cdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -4277,7 +4277,7 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type( /// 入队前必须成立的前置条件:目录可用、读写权限、正文非空、创建类型合法。 /// -/// GUI 命令在入队半调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, +/// GUI 命令在入队侧调用(不成立就是**入队失败**),CLI 入口在起回合前调用。两处共用这一份判据, /// 不要再各自复制一遍条件。 pub(crate) fn check_direct_turn_preconditions( root: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2ea1be4d5..f0feff606 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,7 +30,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 入队半就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt +/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt /// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 /// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// @@ -64,7 +64,7 @@ pub(crate) async fn enqueue_direct_codex_turn( .map_err(|failure| direct_turn_enqueue_failure(root, boundary_turn_id.as_deref(), failure)) } -/// 入队半:全程 typed,顺序固定,**每一步失败都还是入队失败**: +/// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: /// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 /// /// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs index 18425b202..d06608f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_queue.rs @@ -92,7 +92,7 @@ pub(crate) enum EnqueueOutcome { AlreadyKnown, } -/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。 +/// 入队被检查挡下来的原因。满队之外的原因由入队侧自己的 typed 错误表达。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum EnqueueRejection { /// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index ff038a336..4d223ef68 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -64,8 +64,8 @@ 改动点: - `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 - **入队半**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; - 任何一步失败返回 typed 入队失败。**放行半**(第 2 步)从占用登记起。 + **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。**放行侧**(第 2 步)从占用登记起。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From 25cec7955a8814d1f61d0d7f72dbda61dc15697a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:44:36 +0800 Subject: [PATCH 14/28] =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E6=94=B9=E6=88=90=E6=99=AE=E9=80=9A=E5=88=86?= =?UTF-8?q?=E6=94=AF=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=B5=8C=E5=A5=97=E4=B8=89?= =?UTF-8?q?=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `cancelPendingTurn` 里三路结果(不在队 / 已放行 / 已取消)改用 if / else 表达 - 已放行那一路的说明注释挪到对应的分支上 - 不改任何文案与行为 --- .../useDirectProjectChatController.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 91f86394e..86f0238cc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -341,14 +341,17 @@ export function useDirectProjectChatController({ // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 pendingRunAnalyticsRef.current.delete(clientTurnId); } - setComposerNotice( - outcome === 'notFound' - ? '这条待发消息已经不在队列里' - : // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 - outcome === 'alreadyDispatched' - ? '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」' - : '', - ); + if (outcome === 'notFound') { + setComposerNotice('这条待发消息已经不在队列里'); + } else if (outcome === 'alreadyDispatched') { + // 已放行的那一条不能按待发消息取消:它就是正在跑的这一轮,说明清楚该用什么入口。 + setComposerNotice( + '这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」', + ); + } else { + // 取消成功不占用说明行:chip 由事件投影撤掉,用户看到它消失本身就是结果。 + setComposerNotice(''); + } } catch (error) { if (projectPathRef.current !== nextProjectPath) return; setComposerNotice( From a83ed01313463d8caa6db22661d39523999c44e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:45:44 +0800 Subject: [PATCH 15/28] =?UTF-8?q?=E5=88=A0=E6=8E=89=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=B8=94=E6=B0=B8=E4=B8=8D=E6=88=90=E7=AB=8B=E7=9A=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=88=87=E6=8D=A2=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runTurn` 的 catch 里同一判据写了两遍,两处之间只有同步代码、没有 await,`projectPathRef.current` 不可能变 - 保留 await 之后那一遍,删掉其后永远不会成立的第二遍 - 不改行为:守卫效果由前一处完整覆盖 --- .../chat/controller/useDirectProjectChatController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 86f0238cc..4bbcc0f97 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -609,7 +609,6 @@ export function useDirectProjectChatController({ '陶泥儿智能创作', true, ); - if (projectPathRef.current !== nextProjectPath) return true; // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 // 用户以为没发出去、再按一次。 draftSafe = true; From 75f9bbacb1f6d806d3eb3225449a6038ef069ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 11:50:38 +0800 Subject: [PATCH 16/28] =?UTF-8?q?=E5=85=A5=E9=98=9F=E5=89=8D=E5=85=88?= =?UTF-8?q?=E5=88=A4=E5=AE=B9=E9=87=8F=EF=BC=9A=E6=BB=A1=E4=BA=86=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=81=9A=E5=B7=A5=E7=A8=8B=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `direct_thread_manager` 新增只读的 `direct_pending_turn_count`(管理器 `pending_turn_count` + 模块级封装) - `enqueue_direct_codex_turn_typed` 在前置条件之后、工程准备之前先做一次容量预判,满队直接返回 `QueueFull` - 工程准备是分钟级、会在磁盘留产物的活,为注定收不下的消息先做它没有意义;权威判据仍是入队临界区里的检查 - 补一条管理器用例钉住这个只读口径:只数在队条目,认领 / 取消后立刻跟着变 --- .../src/agent/direct_runtime/user_input.rs | 22 +++++--- .../src/agent/direct_thread_manager.rs | 53 +++++++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 5 +- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index f0feff606..d52806b46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,8 +31,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// /// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt -/// 投影 → 前置条件 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过之后只做一件事 -/// ——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 +/// 投影 → 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 +/// 之后只做一件事——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// /// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 /// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `direct_turn_dispatch`), @@ -65,11 +65,12 @@ pub(crate) async fn enqueue_direct_codex_turn( } /// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: -/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队。 +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队。 /// /// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; -/// 没通过检查的东西不进队列。入队本身只做判重与容量,不再有第二种副作用——落盘、回合事件与整轮 -/// 都在放行那一侧。 +/// 没通过检查的东西不进队列。容量放在工程准备之前是"别为注定收不下的消息先付分钟级的代价",它 +/// 与入队那一刻的容量检查同源,只是提前一次。入队本身只做判重与容量,不再有第二种副作用——落盘、 +/// 回合事件与整轮都在放行那一侧。 async fn enqueue_direct_codex_turn_typed( root: &Path, user_item: DirectCodexUserItem, @@ -92,6 +93,16 @@ async fn enqueue_direct_codex_turn_typed( let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; + let thread_id = direct_thread_id_for_project(root); + // 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程 + // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 + // 临界区里的容量检查(下面 `enqueue_direct_pending_turn`):这里只是快速失败,中间被别人的消息 + // 挤满时那一条照样拦得住。 + queue_has_room(direct_pending_turn_count(&thread_id)).map_err(|_| { + DirectTurnError::QueueFull { + limit: MAX_PENDING_DIRECT_TURNS, + } + })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await @@ -112,7 +123,6 @@ async fn enqueue_direct_codex_turn_typed( .map_err(|error| DirectTurnError::InputRejected { detail: error.to_string(), })?; - let thread_id = direct_thread_id_for_project(root); match enqueue_direct_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index ea7dfb10d..2e1fe0208 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -570,6 +570,14 @@ impl DirectThreadManager { .unwrap_or_default() } + /// 当前在队的待发消息条数(容量预判用,只读)。 + fn pending_turn_count(&self, thread_id: &str) -> usize { + self.threads + .get(thread_id) + .map(|thread| Self::pending_turns(thread).len()) + .unwrap_or(0) + } + /// 观察一条事件,返回它本身是否可回收。 fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { match event { @@ -775,6 +783,17 @@ pub(crate) fn enqueue_direct_pending_turn( outcome } +/// 这个 thread 上**在队**的待发消息条数(只读)。 +/// +/// 给入队侧做最便宜的一次容量预判用:满了就先拒绝,不去做工程准备那种分钟级、会在磁盘上留下产物的 +/// 活。权威判据仍然是入队临界区里的 `queue_has_room`,这里只求早失败。 +pub(crate) fn direct_pending_turn_count(thread_id: &str) -> usize { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pending_turn_count(thread_id) +} + /// 取消一条待发消息:结果告诉调用方它是被取消、已经放行,还是本来就不在队里。 pub(crate) fn remove_direct_pending_turn( thread_id: &str, @@ -1462,6 +1481,40 @@ mod tests { ); } + /// 入队侧拿来做容量预判的条数与权威判据同源:只数在队条目,认领 / 取消之后立刻跟着变。 + #[test] + fn pending_turn_count_tracks_only_the_queue() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + assert_eq!(manager.pending_turn_count("thread-unknown"), 0); + for index in 0..MAX_PENDING_DIRECT_TURNS { + manager + .enqueue_pending_turn("thread-1", pending_turn(&format!("turn-{index}"))) + .expect("enqueue"); + } + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS + ); + assert_eq!( + queue_has_room(manager.pending_turn_count("thread-1")), + Err(EnqueueRejection::QueueFull) + ); + + // 认领队首与取消一条在队消息都立刻反映到这条只读口径上。 + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS - 1 + ); + manager.remove_pending_turn("thread-1", "turn-1", FIXED_AT_MS); + assert_eq!( + manager.pending_turn_count("thread-1"), + MAX_PENDING_DIRECT_TURNS - 2 + ); + } + /// 放行认领的是**队首**,而且开始事件与离开队列事件同批写:中间没有第二个中间态。 #[test] fn dispatch_claims_the_head_and_writes_both_events_in_one_batch() { diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 4d223ef68..5a9d29642 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -64,8 +64,9 @@ 改动点: - `agent/direct_runtime/user_input.rs`:把命令主体拆成两半。 - **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 工程准备 → 入队; - 任何一步失败返回 typed 入队失败。**放行侧**(第 2 步)从占用登记起。 + **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队; + 任何一步失败返回 typed 入队失败。容量预判只是一次提前的快速失败,权威判据仍在入队临界区里(工程准备是分钟级、会 + 在磁盘留产物的活,满了就不该先做它)。**放行侧**(第 2 步)从占用登记起。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From d341a9be1339a981820ed5ae2c9f3e1121a010d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:43:20 +0800 Subject: [PATCH 17/28] =?UTF-8?q?=E8=A1=A5=E4=B8=AD=E9=80=94=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E8=80=85=E7=9A=84=E5=90=88=E5=B9=B6=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `late_subscriber_sees_the_running_turn_and_the_pending_queue_together` - 钉住:正在跑那一轮的 `turn.started` 锚点(带开口条目身份)与还排着的 `queue.enqueued` 同时进 bootstrap,顺序按事件序 - 原有用例各覆盖一半(一条只测队列、一条只测锚点),这条覆盖两者同时存在的中途加入场景 --- .../src/agent/direct_thread_manager.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2e1fe0208..17375996e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -1422,6 +1422,44 @@ mod tests { assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); } + /// 中途加入者拿到的是**合并快照**:正在跑那一轮的锚点 + 还没走的队列,顺序就是事件顺序。 + /// + /// 队列搬到宿主之后这件事只能由 live-set bootstrap 顶上:另一个窗口、离开工作台再回来, + /// 看到的状态必须和一直在场的那位一致——在跑的那一轮还在跑(锚点带开口条目身份,条目挂得回去), + /// 排着的那几条还在队,而不是只剩其中一个。 + #[test] + fn late_subscriber_sees_the_running_turn_and_the_pending_queue_together() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue head"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-2")) + .expect("enqueue tail"); + + let bootstrap = manager.subscribe("thread-1"); + assert!( + matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted { .. }, + DirectThreadEvent::QueueEnqueued { client_turn_id, .. } + ] if client_turn_id == "turn-2" + ), + "中途加入者要先看到在跑的那一轮、再看到还排着的那几条:{:?}", + bootstrap.events + ); + assert_eq!( + bootstrap.events[0].user_item_id(), + Some("direct-codex:turn-1:user"), + "锚点必须带着正在跑那一轮的开口条目身份" + ); + assert_eq!(enqueued_ids(&bootstrap.events), vec!["turn-2"]); + } + /// 入队幂等:判重范围是「在队 ∪ 正在跑的那一轮」,重复入队不排第二条、不发第二条事件。 #[test] fn enqueue_is_idempotent_per_client_turn_id() { From e0ca5ad9b93d8c3978cddf9f336586527a40dcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:43:32 +0800 Subject: [PATCH 18/28] =?UTF-8?q?=E5=89=8D=E7=AB=AF=20bootstrap=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E8=A1=A5=E9=BD=90=EF=BC=9A=E5=9C=A8=E8=B7=91?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E8=BD=AE=E4=B8=8E=E5=9C=A8=E9=98=9F?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E5=90=8C=E6=97=B6=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `directThreadChat.test.ts` 队列投影分组里新增一条:bootstrap 同时带 `turn.started` 锚点与两条 `queue.enqueued` - 断言 `turnRunning` / `turnUserItemId` / `pendingTurns` 三者同时正确,而不是各测一半 - 只加用例,不改实现 --- .../tests/directThreadChat.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index ceb68ede4..bf252baff 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -410,6 +410,32 @@ describe('DirectProject 聊天 reducer', () => { [{ type: 'input_text', text: '第二条' }], ]); }); + + it('bootstrap 同时交出在跑的那一轮与在队的那几条:顺序就是事件顺序', () => { + // 中途加入(另一个窗口、离开工作台再回来)拿到的是一份**合并快照**:锚点让"这一轮还在跑" + // 成立,后面的在队条目让队列不丢。只有其中一个都算把界面还原成了错的样子。 + const bootstrap = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_500 }), + 'direct-codex:client-0:user', + ), + enqueued('client-1', '第一条'), + enqueued('client-2', '第二条'), + ], + } as never, + ); + expect(bootstrap.turnRunning).toBe(true); + expect(bootstrap.turnUserItemId).toBe('direct-codex:client-0:user'); + expect(bootstrap.pendingTurns.map((turn) => turn.clientTurnId)).toEqual([ + 'client-1', + 'client-2', + ]); + }); }); describe('失败终态(turn.completed 带 failure 载荷)', () => { From 51e48113445ef3134e11331edef7cbbd5efe815b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 12:55:07 +0800 Subject: [PATCH 19/28] =?UTF-8?q?=E6=AE=8B=E7=95=99=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E9=87=8A=E6=94=BE=E6=94=B9=E6=88=90=20Thread?= =?UTF-8?q?=20Manager=20=E9=87=8C=E7=9A=84=E5=8E=9F=E5=AD=90=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `DirectThreadManager::release_stale_turn` 在同一个临界区里做完"校验 → 解除占用 → 写 aborted 终态" - 判据不满足(没有活动回合 / 身份对不上 / 没过启动窗口)一个字都不写,占用与事件都保持原样 - 拆成"先校验后释放"两次取锁会让并发认领插进中间,清掉新那一轮的占用并给老回合补假终态 - `direct_stale_turn_for_release` 降级成"调用原子出口 + 把结果翻成人话",文案与对外语义不变 - 兜底终态改由 Thread Manager 写,身份取被释放那一轮自己的 clientTurnId,删掉 app-server 侧的建事件助手 - 测试:管理器侧新增"校验与释放是一个动作"用例;运行时侧用例补上"拒绝时零写入 / 通过时占用与终态一起落地" --- .../src/agent/codex_app_server/mod.rs | 52 +----- .../src-tauri/src/agent/direct_runtime/mod.rs | 94 +++++++--- .../src/agent/direct_thread_manager.rs | 168 +++++++++++++++++- 3 files changed, 237 insertions(+), 77 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 2c3ada2fc..030f71631 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -4465,14 +4465,9 @@ enum DirectCodexTurnCancelTarget { /// [`direct_stale_turn_for_release`] 的注释;"正在跑的是另一轮"仍然保持原拒绝语义, /// 什么都不释放。 /// -/// 兜底终态带 `userItemId`:身份取 [`direct_stale_turn_for_release`] 返回的 -/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 -/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 -fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { - DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) - .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) -} - +/// 兜底终态由 Thread Manager 在解除占用的同一个临界区里写下(带 `userItemId`,身份取被释放那一轮 +/// 自己的 `clientTurnId`,与放行时 `turn.started` 同一份 canonical 口径):这一轮不会再有原生终态, +/// 猜一个身份会让前端把边界盖到别人身上。 pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -4523,16 +4518,10 @@ pub(crate) fn cancel_direct_codex_turn_at( }) } DirectCodexTurnCancelTarget::Stale(reason) => { - // 先做前置校验:确实登记着一轮、身份对得上、过了启动窗口才允许判成残留。 + // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; + // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 + // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 let released = direct_stale_turn_for_release(root, client_turn_id, reason)?; - // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), - // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。走 Thread Manager 的 - // 深层出口而不是裸 append:这也是**解除占用的唯一出口**,解除不了这个 thread 会一直 - // 被认为是"还有没收口的回合",挡住后面的放行。 - complete_direct_thread_turn( - &direct_thread_id_for_project(root), - direct_stale_cancel_turn_completed_event(&released), - ); // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_direct_queue_dispatch(root); @@ -6101,35 +6090,6 @@ mod tests { ); } - /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId - /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 - #[test] - fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { - let event = direct_stale_cancel_turn_completed_event("turn-0001"); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); - assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); - assert!(matches!( - event, - DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" - )); - - for missing in ["", " "] { - let event = direct_stale_cancel_turn_completed_event(missing); - assert_eq!( - event.user_item_id(), - None, - "拿不到 clientTurnId 时不得编造开口条目身份" - ); - } - - // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 - assert_eq!( - direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); - } - /// 封口复核要求(`RepairRequired`)是控制流:这一轮还没结束,不能写出失败终态。 /// /// 反过来,真失败必须写成载荷,`kind` / `message` 从 typed 错误投影——两者以前共用 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 964593cdf..64c696479 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -496,7 +496,8 @@ impl DirectStaleTurnReleaseReason { } } -/// 兜底释放前的前置校验:这一轮是不是**可以**被判成残留,返回它的 `clientTurnId`。 +/// 兜底释放一个残留回合:校验与释放**在 Thread Manager 的同一个临界区里**完成,返回被释放的 +/// `clientTurnId`。 /// /// 条件(三条必须同时成立,这段注释就是契约): /// 1. 这个 thread 上确实登记着一轮(Thread Manager 的活动回合); @@ -504,34 +505,33 @@ impl DirectStaleTurnReleaseReason { /// 3. `reason == NeverReachedExecutor` 时,登记的年龄必须超过 /// [`DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS`],排除"刚放行、还在本地准备"的正常启动窗口。 /// -/// **释放本身不在这里**:调用方拿到身份后往 Thread Manager 写一条 `turn.completed{aborted}`, -/// 那才是解除占用的唯一出口(也因此天然幂等——占用已经没了就返回"没有正在运行的回合")。 +/// 不满足任何一条都**什么都不写**(占用、事件都不变),返回可读原因;因此它天然幂等——占用已经 +/// 没了就返回"没有正在运行的回合"。这一层只负责把 [`DirectStaleTurnRelease`] 翻成人话:判据与释放 +/// 动作都在 Thread Manager 里,因为它们必须在同一个临界区里(拆成两步会让并发认领插进中间,见 +/// [`release_stale_direct_turn`])。 pub(crate) fn direct_stale_turn_for_release( root: &Path, expected_client_turn_id: Option<&str>, reason: DirectStaleTurnReleaseReason, ) -> Result { - let thread_id = direct_thread_id_for_project(root); - let active = read_direct_turn_identity(&thread_id) - .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; - if let Some(expected) = expected_client_turn_id + let expected = expected_client_turn_id .map(str::trim) - .filter(|value| !value.is_empty()) - { - if active.client_turn_id != expected { - return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + .filter(|value| !value.is_empty()); + let min_age_ms = (reason == DirectStaleTurnReleaseReason::NeverReachedExecutor) + .then_some(DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS); + match release_stale_direct_turn(&direct_thread_id_for_project(root), expected, min_age_ms) { + DirectStaleTurnRelease::Released(client_turn_id) => Ok(client_turn_id), + DirectStaleTurnRelease::NoActiveTurn => { + Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()) } - } - if reason == DirectStaleTurnReleaseReason::NeverReachedExecutor { - let age_ms = direct_taonier_active_now_millis().saturating_sub(active.started_at); - if age_ms < DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS { - return Err(format!( - "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", - age_ms / 1000 - )); + DirectStaleTurnRelease::AnotherTurnRunning => { + Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()) } + DirectStaleTurnRelease::WithinStartupWindow { age_ms } => Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )), } - Ok(active.client_turn_id) } fn direct_taonier_regeneration_project_id(root: &Path) -> Result { @@ -5542,12 +5542,15 @@ mod tests { assert!(direct_active_turn_id_at(root.path()).is_err()); } - /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口。 + /// 兜底释放的前置判据:身份对得上,且"从没进执行器"必须过启动窗口;判据不过**一个字都不写**, + /// 通过则占用与兜底终态在同一个临界区里一起落地。 #[test] fn stale_turn_release_requires_a_matching_identity_and_only_after_the_start_window() { let root = tempfile::tempdir().expect("stale turn root"); let thread_id = direct_thread_id_for_project(root.path()); let reservation = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-1"); + let watcher = subscribe_direct_thread(&thread_id); + let _ = consume_direct_thread(&watcher.subscription_id).expect("drain bootstrap"); // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 let mismatch = direct_stale_turn_for_release( @@ -5557,10 +5560,6 @@ mod tests { ) .expect_err("another turn must not be released"); assert!(mismatch.contains("另一条"), "{mismatch}"); - assert_eq!( - read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), - Some("client-turn-stale-1".to_string()) - ); // ② 刚放行、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 let young = direct_stale_turn_for_release( @@ -5571,7 +5570,20 @@ mod tests { .expect_err("a freshly dispatched turn is still starting"); assert!(young.contains("暂不能强制释放"), "{young}"); - // ③ 老过窗口:判成残留,调用方拿到身份(解除占用由调用方写终态完成)。 + // ① ② 两条拒绝路径都**什么都没写**:占用还在,事件流上也没有任何东西。 + assert_eq!( + read_direct_turn_identity(&thread_id).map(|turn| turn.client_turn_id), + Some("client-turn-stale-1".to_string()) + ); + assert!( + consume_direct_thread(&watcher.subscription_id) + .expect("consume after rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // ③ 老过窗口:判成残留——占用与兜底终态在同一个临界区里一起落地。 backdate_direct_active_turn_for_test(&thread_id, DIRECT_STALE_TURN_RELEASE_MIN_AGE_MS + 1); let released = direct_stale_turn_for_release( root.path(), @@ -5580,7 +5592,33 @@ mod tests { ) .expect("stale turn is releasable"); assert_eq!(released, "client-turn-stale-1"); - // ④ "执行进程已退出"不看启动窗口:刚放行也允许释放。 + assert_eq!( + read_direct_turn_identity(&thread_id), + None, + "占用必须一起解除" + ); + let events = consume_direct_thread(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + let terminal = &events[0]; + assert!( + matches!( + terminal, + DirectThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ), + "{terminal:?}" + ); + assert_eq!( + terminal.user_item_id().as_deref(), + Some("direct-codex:client-turn-stale-1:user"), + "兜底终态的身份取被释放那一轮自己的 clientTurnId" + ); + assert!(terminal.at().is_some(), "兜底终态仍要带宿主观测时间"); + + // ④ 释放之后这个 thread 立刻腾出来了(占用解除与终态同批落地):下一条能重新认领, + // 而且"执行进程已退出"不看启动窗口——刚放行也允许释放。 + let _next = DirectTurnReservation::accept_for_test(&thread_id, "client-turn-stale-2"); assert_eq!( direct_stale_turn_for_release( root.path(), @@ -5588,7 +5626,7 @@ mod tests { DirectStaleTurnReleaseReason::ExecutorExited, ) .expect("executor exited ignores the start window"), - "client-turn-stale-1" + "client-turn-stale-2" ); drop(reservation); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 17375996e..017d41edd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -9,9 +9,10 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_tool_call_now_ms, queue_has_room, DirectQueueRemovalOutcome, DirectQueueRemovalReason, - DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, - EnqueueOutcome, EnqueueRejection, PendingDirectTurn, + direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room, + DirectQueueRemovalOutcome, DirectQueueRemovalReason, DirectThreadConsumeResult, + DirectThreadEvent, DirectThreadSubscriptionBootstrap, EnqueueOutcome, EnqueueRejection, + PendingDirectTurn, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -96,6 +97,22 @@ pub(crate) struct DirectTurnIdentity { pub(crate) started_at: u64, } +/// 兜底释放一个残留回合的结果。 +/// +/// 除了 [`Self::Released`],其余三种都是**什么都没写**:占用没动、事件没发。调用方据此给用户 +/// 不同的话;把"校验"和"释放"拆成两步就会丢掉这个性质(见 [`DirectThreadManager::release_stale_turn`])。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DirectStaleTurnRelease { + /// 占用已解除,`turn.completed{aborted}` 已下发:这个 thread 现在可以放行下一条。 + Released(String), + /// 这个 thread 上没有未收口的回合。 + NoActiveTurn, + /// 登记的回合身份与调用方给的不一致:绝不误伤另一条回合。 + AnotherTurnRunning, + /// 登记的年龄还没过启动窗口(`age_ms` 是登记到 `now_ms` 的差)。 + WithinStartupWindow { age_ms: u64 }, +} + /// 一次放行的结果:队首那条待发消息,以及这次放行的占用身份。 /// /// `token` 是这一轮的占用身份(终态出口只认它),`pending` 是放行要用的**全部**输入——放行不再 @@ -327,6 +344,58 @@ impl DirectThreadManager { true } + /// 兜底释放一个残留回合:**同一个临界区里**做完"校验 → 解除占用 → 写终态"。 + /// + /// 这三件事必须原子。拆成"先校验、后释放"两次取锁的话,两步之间占用可能已经被真实收口释放, + /// 队列顺势认领了下一条并登记新占用——那时再无条件解除占用,就会清掉**新那一轮**的占用,还给 + /// 已经结束的老回合补一条假终态。这正是"多轮同时在跑 / 终态张冠李戴"那一类事故。 + /// + /// 三条前置条件与 [`DirectStaleTurnRelease`] 的三个失败分支一一对应: + /// 1. 这个 thread 上确实登记着一轮; + /// 2. 传了 `expected_client_turn_id` 时必须与登记一致; + /// 3. `min_age_ms` 有值时,登记的年龄(`now_ms - started_at`)必须不小于它——用来排除 + /// "刚放行、还在本地准备"的正常启动窗口。 + /// + /// 不满足任何一条都**一个字都不写**(占用与事件都保持原样)。通过则终态与占用一起落地:占用 + /// 一解除,队列就可以认领下一条。 + fn release_stale_turn( + &mut self, + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, + now_ms: u64, + ) -> DirectStaleTurnRelease { + let released = { + let Some(thread) = self.threads.get_mut(thread_id) else { + return DirectStaleTurnRelease::NoActiveTurn; + }; + let Some(active) = thread.active_turn.take() else { + return DirectStaleTurnRelease::NoActiveTurn; + }; + if expected_client_turn_id.is_some_and(|expected| expected != active.turn_id.as_str()) { + // 身份对不上:放回去,绝不误伤另一条回合。 + thread.active_turn = Some(active); + return DirectStaleTurnRelease::AnotherTurnRunning; + } + let age_ms = now_ms.saturating_sub(active.started_at); + if min_age_ms.is_some_and(|min_age_ms| age_ms < min_age_ms) { + thread.active_turn = Some(active); + return DirectStaleTurnRelease::WithinStartupWindow { age_ms }; + } + active.turn_id + }; + // 这一轮不会再有人替它发终态(执行进程已退出 / 从没进执行器):补一条 aborted,否则前端的 + // "最新回合是否在跑"会永远停在运行中。身份用被释放那一轮自己的 `turn_id` 推导,与放行时 + // `turn.started` 的口径同一份(`PendingDirectTurn::user_item_id`)。 + self.append( + thread_id, + DirectThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), + ), + ); + DirectStaleTurnRelease::Released(released) + } + /// 这个 thread 上正在跑的那一轮身份;`None` = 没有未收口的回合。 fn active_turn_identity(&self, thread_id: &str) -> Option { self.threads.get(thread_id).and_then(|thread| { @@ -881,6 +950,31 @@ pub(crate) fn complete_direct_thread_turn_if_reserved( written } +/// 兜底释放一个残留回合:校验与释放**同一个临界区**(见 [`DirectThreadManager::release_stale_turn`])。 +/// +/// 只有真释放了才通知订阅者:校验不过时什么都没写,不该惊动任何人。 +pub(crate) fn release_stale_direct_turn( + thread_id: &str, + expected_client_turn_id: Option<&str>, + min_age_ms: Option, +) -> DirectStaleTurnRelease { + let outcome = { + global_direct_thread_manager() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .release_stale_turn( + thread_id, + expected_client_turn_id, + min_age_ms, + direct_tool_call_now_ms(), + ) + }; + if matches!(outcome, DirectStaleTurnRelease::Released(_)) { + notify_direct_thread_subscribers(thread_id); + } + outcome +} + /// 这个 thread 上正在跑的那一轮身份。 /// /// 只读:占用仍然只由终态出口(深层终态 / 占用对象兜底 / 兜底释放)解除,这里不改任何状态。 @@ -1596,6 +1690,74 @@ mod tests { assert_eq!(manager.pending_turn_ids("thread-1"), vec!["turn-2"]); } + /// 兜底释放是**一个动作**:判据不过时占用与事件都不动,通过时占用解除与兜底终态一起落地。 + #[test] + fn stale_release_validates_and_releases_as_one_action() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .enqueue_pending_turn("thread-1", pending_turn("turn-1")) + .expect("enqueue"); + manager + .claim_pending_turn("thread-1", FIXED_AT_MS) + .expect("claim head"); + let watcher = manager.subscribe("thread-1"); + let _ = manager.consume(&watcher.subscription_id).expect("drain"); + + // 身份对不上:放回去,占用不动、一个字都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", Some("turn-other"), None, FIXED_AT_MS + 10_000), + DirectStaleTurnRelease::AnotherTurnRunning + ); + // 启动窗口内:同样什么都不写。 + assert_eq!( + manager.release_stale_turn("thread-1", None, Some(60_000), FIXED_AT_MS + 1), + DirectStaleTurnRelease::WithinStartupWindow { age_ms: 1 } + ); + assert!(manager.turn_is_active("thread-1")); + assert!( + manager + .consume(&watcher.subscription_id) + .expect("consume rejected releases") + .events + .is_empty(), + "判据不过不许写任何事件" + ); + + // 通过:占用解除与兜底终态在同一个临界区里落地,身份取被释放那一轮自己的 turn_id。 + assert_eq!( + manager.release_stale_turn( + "thread-1", + Some("turn-1"), + Some(60_000), + FIXED_AT_MS + 60_000 + ), + DirectStaleTurnRelease::Released("turn-1".to_string()) + ); + assert!(!manager.turn_is_active("thread-1")); + let events = manager + .consume(&watcher.subscription_id) + .expect("consume released terminal") + .events; + assert_eq!(events.len(), 1, "兜底释放只写一条终态:{events:?}"); + assert!( + matches!( + events[0], + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ), + "{events:?}" + ); + assert_eq!( + events[0].user_item_id().as_deref(), + Some("direct-codex:turn-1:user") + ); + + // 占用已经解除,再释放就是"没有活动回合"。 + assert_eq!( + manager.release_stale_turn("thread-1", None, None, FIXED_AT_MS + 60_000), + DirectStaleTurnRelease::NoActiveTurn + ); + } + /// 取消的三种结果分得开:真的移除了、已经被放行了、本来就不在队里。 #[test] fn removal_tells_cancelled_dispatched_and_unknown_apart() { From 9f04940c6d6c39ed0be211b5966819cfd83e7e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 13:04:49 +0800 Subject: [PATCH 20/28] =?UTF-8?q?=E6=95=B4=E5=8C=85=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=81=A2=E5=A4=8D=E5=8F=AA=E5=9C=A8=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=97=B6=E6=89=8D=E5=8F=96=E6=95=B4=E8=BD=AE?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `recover_direct_taonier_regeneration_workflow_at` 先做只读判据(复用 `direct_taonier_regeneration_workflow_requires_recovery_at`),不需要恢复就直接返回 - 真要动手才取 `direct-codex-art`,并在锁下重读一次判据(以锁下那一份为准) - 原来无条件取锁:正在跑的整包重生成整轮持有它,入队会先同步阻塞约 1 秒再被 `HostStateUnavailable` 拒掉 - 实施计划补一行说明,与 ADR §6"入队不取整轮持有的锁"对齐 - 测试:新增用例"整包重生成在跑(锁被占)时不需要恢复的入队路径不取锁",撤掉修复会红 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 90 +++++++++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 2 + 2 files changed, 92 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 64c696479..bc0c140c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1072,8 +1072,19 @@ pub(in crate::agent) fn direct_taonier_regeneration_workflow_requires_recovery_a pub(in crate::agent) fn recover_direct_taonier_regeneration_workflow_at( root: &Path, ) -> Result { + // 锁只在**真的需要恢复**时才取:正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`,入队 + // 命令若无条件去取它,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒(`..._with_wait` + // 是 `std::thread::sleep`),然后被 `HostStateUnavailable` 拒掉——而队列本该收下它。 + // 判据本身是只读的:没有工作流 / 状态不需要恢复时,这条路一个字都不写。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let _executor_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, "direct-codex-art")?; + // 判据以**锁下那一份**为准:两次读之间真的重生成可能已经推进,或者别的进程已经恢复完了。 + if !direct_taonier_regeneration_workflow_requires_recovery_at(root)? { + return Ok(false); + } let Some(workflow) = read_direct_taonier_regeneration_workflow_at(root)? else { return Ok(false); }; @@ -5632,6 +5643,85 @@ mod tests { } /// 没有任何活动回合时,兜底释放给出可读原因,而不是静默成功。 + /// 不需要恢复时**不取**整轮任务锁。 + /// + /// 正在跑的那一轮整包重生成会整轮持有 `direct-codex-art`;入队命令的第一步就是这里,若无条件 + /// 取锁,整包重生成期间每条消息都要先在 worker 上同步阻塞约 1 秒,再被 `HostStateUnavailable` + /// 拒掉——队列本该收下它。判据本身只读,不需要恢复就一个字节都不写、一把锁都不取。 + #[test] + fn recovery_skips_the_executor_task_lock_when_nothing_needs_recovery() { + let root = tempfile::tempdir().expect("recovery lock probe"); + init_local_game_project_at(root.path(), "direct-recovery-lock-probe", "恢复不取锁") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + // 有人正持有这一轮的任务锁:等价于"整包重生成正在跑"。 + let held = + try_acquire_game_creator_agent_runtime_task_lock(root.path(), "direct-codex-art") + .expect("probe the art task lock") + .expect("hold the art task lock"); + + // ① 完全没有工作流:不需要恢复,不去取锁。 + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("an absent workflow needs no recovery") + ); + + // ② 正在正常进行中的整包重生成(InProgress、没有待恢复的替换锚点):这正是"整包重生成在跑" + // 的那一刻——不需要恢复,更**不该去取**那把整轮持有的锁。 + let in_progress = { + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root.path(), + "direct-codex.test-in-progress-lock-probe", + ) + .expect("project write lock"); + new_direct_taonier_regeneration_workflow_locked( + root.path(), + &direct_taonier_regeneration_intent_sha256("正在跑的一套美术"), + &direct_taonier_regeneration_invocation_sha256("client-turn-in-progress-probe"), + DirectTaonierRegenerationWorkflowState::InProgress, + ) + .expect("build in-progress workflow") + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &in_progress) + .expect("persist the in-progress workflow"); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("an in-progress workflow is discoverable") + ); + assert!( + !recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("a running regeneration needs no recovery"), + "整包重生成在跑时入队不该去取整轮持有的锁" + ); + + // ③ 真需要恢复(Resetting)时仍然要取锁:拿不到就如实报错,绝不当成"恢复过了"。 + let resetting = DirectTaonierRegenerationWorkflow { + state: DirectTaonierRegenerationWorkflowState::Resetting, + ..in_progress + }; + write_direct_taonier_regeneration_workflow_at(root.path(), &resetting) + .expect("persist the resetting workflow"); + assert!( + direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("a resetting workflow is discoverable") + ); + let blocked = recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect_err("a needed recovery must still take the task lock"); + assert!(blocked.contains("direct-codex-art"), "{blocked}"); + + // ④ 锁一放开照旧恢复。 + drop(held); + assert!(recover_direct_taonier_regeneration_workflow_at(root.path()) + .expect("resetting recovery resumes once the lock is free")); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read recovered workflow") + .expect("recovered workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + } + #[test] fn stale_turn_release_without_an_active_turn_says_so() { let root = tempfile::tempdir().expect("empty turn root"); diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 5a9d29642..0523b4965 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -67,6 +67,8 @@ **入队侧**:`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队; 任何一步失败返回 typed 入队失败。容量预判只是一次提前的快速失败,权威判据仍在入队临界区里(工程准备是分钟级、会 在磁盘留产物的活,满了就不该先做它)。**放行侧**(第 2 步)从占用登记起。 + 其中"工作流恢复"只在**真的需要恢复**时才去取整轮任务锁(`direct-codex-art`):正在跑的那一轮整包重生成整轮持有 + 它,入队无条件取锁会先同步阻塞约 1 秒再被拒(见 ADR §6"入队不取整轮持有的锁")。 - 队列条目(宿主侧产物,只在内存):`PendingDirectTurn { client_turn_id, user_item: Value, prompt: String, creation_type: Option, at: u64 }`。 `prompt` 与 `creation_type` 是入队检查的产物,放行不再重算;事件里**不带** `prompt`。 - `agent/direct_thread_manager.rs`: From e22154394743f60f5187a2eb82e404cbc5c04e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 13:11:45 +0800 Subject: [PATCH 21/28] =?UTF-8?q?=E7=BB=88=E6=AD=A2=E9=92=AE=E6=8C=89=20ca?= =?UTF-8?q?nStopTurn=20=E6=98=BE=E7=A4=BA=EF=BC=9A=E9=98=9F=E5=88=97?= =?UTF-8?q?=E9=9D=9E=E7=A9=BA=E4=B8=8D=E5=86=8D=E9=A1=B6=E5=87=BA=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useDirectProjectTurnStatus` 新增派生判据 `canStopTurn`(`nativeRunning || commandInFlight`)与共享的 `canStopDirectProjectTurn`,写清它与 `displayBusy` 的分工:前者回答"终止这一脚踢给谁",后者回答"界面能不能声称空闲" - composer 的发送钮 / 终止钮按 `canStopTurn` 换;`busy` 继续管模型 / 附件 / 语音这些与当前这轮冲突的控件 - `cancelTurn` 的受理判据改用同一个函数:显示与受理同源,不再出现"按钮在、点了说没有回合" - `displayBusy` 保持三者并集不变(卡片仍要早于首个 token 显示"正在处理") - 测试:`directProjectTurnStatus` 补 `canStopTurn` 用例;appSurface 补"只有待发消息、没有回合在跑时不显示终止钮"(撤掉修复会红) --- .../chat/DirectProjectChatView.tsx | 1 + .../DirectProjectComposer.tsx | 14 ++++++- .../useDirectProjectChatController.ts | 8 +++- .../controller/useDirectProjectTurnStatus.ts | 25 ++++++++++++ .../tests/appSurface/chat-composer.suite.ts | 28 ++++++++++++++ .../tests/directProjectTurnStatus.test.ts | 38 ++++++++++++++++++- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index f1c00df6d..94600a01a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -272,6 +272,7 @@ export function DirectProjectChatView({ pendingTurns={pendingTurns} composerNotice={composerNotice} busy={turnStatus.displayBusy} + canStopTurn={turnStatus.canStopTurn} turnCancelling={turnCancelling} onCancelPendingTurn={(clientTurnId) => void cancelPendingTurn(clientTurnId) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index 97e3ae34d..f151e8344 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -57,6 +57,7 @@ export function DirectProjectComposer({ pendingTurns, composerNotice, busy, + canStopTurn, turnCancelling, onCancelPendingTurn, onCancelTurn, @@ -72,7 +73,15 @@ export function DirectProjectComposer({ attachmentsImporting: boolean; pendingTurns: readonly DirectPendingTurn[]; composerNotice: string; + /** 「界面忙不忙」:三者并集,用来锁住模型 / 附件 / 语音这些与当前这轮冲突的控件。 */ busy: boolean; + /** + * 「现在点终止有没有作用对象」(见 `useDirectProjectTurnStatus` 的 `canStopTurn`)。 + * + * 与 `busy` 分开:队列非空也算忙,但待发消息不属于「终止」——按 `busy` 显示终止钮会得到一个 + * 点了只会报「当前没有正在运行的回合」的按钮。发送钮 / 终止钮只按它换。 + */ + canStopTurn: boolean; turnCancelling: boolean; onCancelPendingTurn: (clientTurnId: string) => void; onCancelTurn: () => void; @@ -159,7 +168,8 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); - // 这个按钮只在非 busy 分支出现(busy 时由 ComposerStopButton 接管),所以不再带忙态逻辑。 + // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), + // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = (