From e70b3389001e44c9044ee381e286278cd05a4169 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 15:20:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9ADirectProject=20?= =?UTF-8?q?=E5=BE=85=E5=8F=91=E6=B6=88=E6=81=AF=E6=9D=A1=E7=9B=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BA=8B=E4=BB=B6=E6=8A=95=E5=BD=B1=EF=BC=8Cprompt=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=9D=A1=E7=9B=AE=E9=87=8D=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=20-=20ADR=20=C2=A73/=C2=A74/=C2=A75=20=E4=B8=8E=E5=A4=87?= =?UTF-8?q?=E9=80=89=E6=96=B9=E6=A1=88=206=E2=80=938=E3=80=81=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E4=B8=8E=E4=BB=A3=E4=BB=B7=EF=BC=9A=E5=88=A0=E6=8E=89?= =?UTF-8?q?=20StoredEvent.pending=20=E7=9A=84=E7=AC=AC=E4=BA=8C=E4=BB=BD?= =?UTF-8?q?=E4=BA=A7=E7=89=A9=EF=BC=8C=E9=98=9F=E5=88=97=E6=88=90=E5=91=98?= =?UTF-8?q?=E7=94=B1=E4=BA=8B=E4=BB=B6=E6=8A=98=E5=87=BA=EF=BC=9Bprompt=20?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E5=BC=95=E7=94=A8=20part=20=E4=B8=8A?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E7=AE=97=E4=B8=8D=E5=87=BA=E7=9A=84=E7=89=87?= =?UTF-8?q?=E6=AE=B5=EF=BC=88to=5Fprompt=5Fcache=EF=BC=8C=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E5=86=85=E5=AD=98=E4=B8=93=E7=94=A8=EF=BC=89=EF=BC=8C?= =?UTF-8?q?canonical=20=E5=BD=A2=E7=8A=B6=E4=B8=8E=20prompt=20=E6=94=BE?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=87=8D=E6=8A=95=E5=BD=B1=EF=BC=9Banalytics?= =?UTF-8?q?AttemptId=20=E8=BF=9B=20queue.enqueued=20-=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=9A=E7=8A=B6=E6=80=81=E6=94=B9=E7=AC=AC?= =?UTF-8?q?=200=E2=80=936=20=E6=AD=A5=E3=80=81=E8=90=BD=E5=9C=B0=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E8=A1=A8=E8=A1=A5=E7=AC=AC=206=20=E6=AD=A5=E3=80=81?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E7=AC=AC=206=20=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E4=B8=8D=E5=86=8D=E5=8F=A6=E5=AD=98=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E3=80=8D=E7=9A=84=E5=AE=9E=E7=8E=B0=E7=82=B9=E4=B8=8E?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=20-=20decision-log=EF=BC=9A=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=202026-09-30=20=E4=BF=AE=E8=AE=A2=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=EF=BC=88=E8=83=8C=E6=99=AF=E3=80=81=E4=B8=89=E6=9D=A1=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E3=80=81=E5=9F=8B=E7=82=B9=E8=BA=AB=E4=BB=BD=E7=9A=84?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...命令入队化与待发消息队列归宿主-2026-09-24.md | 35 ++++++++++++++----- .../shared-memory/decision-log.md | 24 +++++++++++++ ...ject命令入队化与待发消息队列归宿主-2026-09-24.md | 29 +++++++++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index 165ee96a6..ca2f6889a 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -1,6 +1,7 @@ # 【ADR】DirectProject命令入队化与待发消息队列归宿主 -状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**,实施顺序与验收见 +状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**;**2026-09-30 修订**:队列条目不再另存产物, +`prompt` 与 canonical 形状改为放行时从条目重投影,见 §3 / §4 / §5 与「备选方案与取舍」6–8 条。实施顺序与验收见 [`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) ## 背景 @@ -39,14 +40,17 @@ ### 3. 队列归 Thread Manager - 每个 thread(线程身份就是项目规范路径)一条 FIFO;本期只支持**按顺序追加**与**按身份移除**,不做重排、优先级、编辑。 -- 队列状态**就是事件列表本身**:`queue.enqueued` 在条目仍在队期间不可回收,离开队列(取消或放行)时转为可回收并被既有规则回收。 - 不新增第二张队列表;`subscribe` 的 live-set bootstrap 因此天然把当前队列交给新订阅者——这就是"加入者看到的那几条"。 +- 队列**就是事件列表本身**:在队条目 = 事件窗口里"有 `queue.enqueued`、且还没有配对 `queue.removed`"的那些事件,事件顺序即队首到队尾。 + 宿主不为条目另存第二份产物(`StoredEvent` 上没有"挂着的队列条目"这种字段),成员与顺序都从事件折出来。 + `queue.enqueued` 在队期间不可回收,离开队列(取消或放行)时与配对的 `queue.removed` 一起转可回收并被既有规则回收。 + `is_bootstrap_event` 不改——`subscribe` 的 live-set bootstrap 因此天然把当前队列交给新订阅者,这就是"加入者看到的那几条"。 - 上限 5 只数**在队条目**(不数正在跑的那一轮),由 Rust 持有;满队时入队失败并给出既有提示文案。 ### 4. 线上形状 -- `queue.enqueued { clientTurnId, userItem, creationType?, at }`:`userItem` 是 canonical 用户条目,前端据此派生 chip 文案, - Rust 不渲染、不裁成展示形状。事件**不带** prompt——prompt 是入队检查的产物,只留在宿主的队列条目里。 +- `queue.enqueued { clientTurnId, userItem, creationType?, analyticsAttemptId?, at }`:`userItem` 是 canonical 用户条目, + 前端据此派生 chip 文案,Rust 不渲染、不裁成展示形状;`analyticsAttemptId` 是发送侧在入队那一刻给这一条消息的埋点身份, + 放行时用它把这一轮的成绩写成埋点候选。事件**不带** prompt——prompt 不是下发形状,放行时由条目重投影(§5)。 - `queue.removed { clientTurnId, reason }`:`reason` 是 ts-rs 导出的 **typed 枚举**(`cancelled | dispatched`),永不用字符串, 形状与 `turn.completed{status, failure?}` 同构。 - 两条事件与其它运行态事件同一条流、同一个 reducer。 @@ -59,6 +63,11 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 **放行不重跑任何检查,也不存在"放行失败"这种状态**:放行之后的一切失败都是**回合失败**,走既有 `turn.completed.failure` 通道; 不新增任何失败通道,也不为放行补拒单出口。 +prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入,其中唯一算不出的片段——引用 UI 设计文档时要展开的代码上下文, +渲染它会往项目里写 `ui/generated-*.js`——在入队检查时冻结到该引用 part 的 `to_prompt_cache` 上。 +这个字段**宿主内存专用**:不序列化、不上线、不进历史、不进 TS 绑定。于是放行侧的 prompt 是条目的纯投影:零 IO、零校验、 +不可失败,与"放行不重跑任何检查"同一个口径。 + ### 6. 放行的触发与监护顺序 - 唯一放行点:回合任务收尾之后的 `kick`(正常 / 失败 / 中止三条路径共用,外加一个 drop 守卫盖 panic),幂等,并且在临界区里原子认领队首。 @@ -107,12 +116,21 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 3. **一条 `queue.changed{items:[…]}` 快照事件**代替两条细粒度事件:reducer 更傻,但每次变更搬全量、与既有细粒度事件风格不一致。不选。 4. **`reason` 用字符串**:前端只能猜、无法穷举、无法在类型层穷尽分支。不选,用 typed 枚举。 5. **保留 CLI**:省掉夹具改写,但等于为手工验证工具长期保留第二条直接起回合的路径,与"命令只有入队一个入口"冲突。不选。 +6. **宿主侧另存条目产物**(`StoredEvent.pending` 挂 `clientTurnId` / `userItem` / `canonical_user_item` / `prompt` / `creation_type` / `at`): + 前四项与 `queue.enqueued` 的载荷是同一份事实的第二、第三份拷贝,取消与放行要同时改两处,回收规则里还得加"产物还在就不算可回收" + 的防御来兜住不一致。作废:条目就是事件载荷的投影,claim 时现推、不存。 +7. **把整条 prompt 存进条目**:prompt 是 `userItem` 的投影,存整条等于把 `userItem` 的 JSON 再存一遍;唯一的例外是引用 UI 设计文档时 + 要写盘的那段代码上下文。改为只把算不出的片段冻结在引用 part 上,整条 prompt 从条目重投影。 +8. **用 `clientTurnId` 兼作埋点身份**:埋点候选的 `attempt_id` 必须是 UUID(`analytics/run.rs` 的 `validate`), + 而 `clientTurnId` 在 WebView 没有 `crypto.randomUUID` 时会退化成时间戳 + 序号的形状。不选,两个身份各留在自己那一层。 ## 影响与代价 - **入队即写盘**:引用 UI 设计文档的条目在 prompt 投影时会生成 `ui/generated-.js`(`ui_editor/persistence.rs`); 从队列里取消不撤该文件。 - **检查是时间点事实**:放行不重跑,按入队那一刻的结论放行;manifest、权限、目录在入队之后变化也照旧放行,偏差落到回合失败。 +- **prompt 形状随冻结片段走**:引用的 UI 设计文档代码上下文不再统一追加在 prompt 末尾,而是跟在它所属的引用片段里。 + 只引用一条时 prompt 与改前逐字节相同;多条引用时块的先后变、语义不变。 - **队列随进程消失**:待发消息只在内存与事件流里,`kill -9` / 退出后重进看不到(与 09-16 ADR 的 `kill -9` 口径一致)。 - **退役面**:前端 `completionPendingRef`、`handledCompletedTurnCountRef`、`busyBaselineTurnCountRef`、`dispatchNextQueuedTurn`、 `queueSequenceRef`、`queuedTurns*` 与 `chatComposerQueue.ts` 整体退役(含它的上限常量与"队列已满"文案—— @@ -125,9 +143,10 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 -- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主(放行不重算,与其它入队检查产物一样 - 存进队列条目),但前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表, - 回合终态按本轮开口条目的 canonical 身份认领结算,入队失败的那一条直接删掉不结算。 +- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主,宿主把它写进 `queue.enqueued`——它是这一条消息的埋点身份, + 与 `clientTurnId` 一样属于"发送侧在这一刻定下的身份",所以跟着事件走而不是另存条目产物。放行时用它把这一轮的成绩写成埋点候选。 + 前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表,回合终态按本轮开口条目的 canonical 身份认领结算, + 入队失败的那一条直接删掉不结算。 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 ## 明确不做 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ea9b3d6f4..b9c2b96a0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9143,6 +9143,30 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 `directTurnPresentation` / `project-conversation` 定向用例、`npm --workspace apps/ai-game-creator-shell run typecheck`、 `npm run check:encoding`、`git diff --check`。 +## 2026-09-30 待发消息条目不再另存产物:队列成员由事件折出,prompt 改为条目重投影 + +- 背景:09-24 落地时给 `StoredEvent` 加了一个"挂着的队列条目"字段(`Option`),条目上带七个字段。 + 复核发现其中 `client_turn_id` / `user_item` / `creation_type` / `at` 与 `queue.enqueued` 的载荷是同一份事实的第二份拷贝, + `canonical_user_item` 是 `serde_json::to_value(user_item)`(第三份),而这两项在构造事件之后再没被读过; + `Some/None` 同时兼着"宿主产物袋"与"在队标记"两职,于是取消 / 放行要写两处事实、回收规则还要加"产物还在就不许回收"的防御。 + 与该 ADR §3 写的"队列的成员与顺序就是事件列表本身"已经不一致。 +- 决策(队列成员):删掉 `StoredEvent.pending`(`append_inner` 并入 `append`);在队条目 = 事件窗口里"有 `queue.enqueued`、 + 且还没有配对 `queue.removed`"的那些事件,按事件顺序即队首到队尾。判重、容量、取消、认领、bootstrap 全部改读这一次折叠, + `mark_queue_events_cleanable` 删掉产物防御。 +- 决策(prompt):不另存整条 prompt。prompt 是 `userItem` 的投影,唯一的例外是引用 UI 设计文档时要展开的代码上下文 + (渲染它会往项目里写 `ui/generated-*.js`)。把它在那次入队检查里冻结到引用 part 的 `to_prompt_cache` 上—— + 该字段宿主内存专用(`#[serde(skip)]` + `#[ts(skip)]`:不序列化、不上线、不进历史、不进绑定)。 + 投影拆成两半:入队侧的 `freeze`(校验 + 算片段 + 写盘)与放行侧的纯折叠(零 IO、零校验、无失败出口), + 于是"放行不重跑任何检查、没有放行失败"这条不变式对新形状仍然成立。`canonical_user_item` 同理改为放行时 + `serde_json::to_value(user_item)` 重投影。副作用:引用的代码上下文片段不再统一追加在 prompt 末尾,而是跟在它所属的引用片段里, + 单条引用时逐字节不变。 +- 决策(埋点身份):`analyticsAttemptId` 由前端在入队那一刻生成、随命令交给宿主,宿主把它写进 + `queue.enqueued{clientTurnId,userItem,creationType?,analyticsAttemptId?,at}`。理由是它与 `clientTurnId` 同类—— + 发送侧在这一刻定下的身份、放行时要原样交给埋点候选——所以跟着事件走,而不是另存条目产物。 + 不用 `clientTurnId` 兼作埋点身份:埋点候选的 `attempt_id` 必须是 UUID(`analytics/run.rs` 的 `validate`), + 而 `clientTurnId` 在 WebView 没有 `crypto.randomUUID` 时会退化成时间戳 + 序号的形状。 +- 验证方式(设计稿,尚未实施):ADR §3 / §4 / §5 与「备选方案与取舍」6–8 条、实施计划第 6 步。 + ## 2026-09-28 渲染层下沉分支与最新 master 对齐:活动回合事实源、维护态出口与诊断详情 - 背景:`codex/agc-renderer-io-downshift` 把 AGC 渲染层的网络与状态下沉 Rust 之后,要重新落到 master 已经演进出的新形状上。29 处冲突里 25 处是机械取舍,真正的分歧只有三处需要定口径。 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index df659f560..e413e4668 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–5 步全部完成,2026-09-30) +状态:**已落地**(第 0–6 步全部完成,2026-09-30) 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -12,11 +12,12 @@ | 步骤 | 状态 | 落地说明 | | --- | --- | --- | | 第 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` | +| 第 1 步 命令 = 入队 | 已落地 | `enqueue_direct_codex_turn`(`+_typed`);队列条目落在 `agent/direct_thread_queue.rs`(第 6 步改为事件投影,不再另存 `prompt` / canonical 形状) | | 第 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 步 守卫清理 | 已落地 | 五个身份读者改读 `direct_active_turn_id_at`;删 `DirectTaonierActiveInvocationGuard` / `DirectActiveTurnView` / 只读探测与 `release_stale_direct_taonier_active_invocation`(改 `direct_stale_turn_for_release` 只做前置校验);删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定;补前置判据用例 | +| 第 6 步 条目不再另存产物 | 已落地 | 删 `StoredEvent.pending` 与 `PendingDirectTurn` 的存储形态(改事件投影);`prompt` 片段冻结进 `AgcResourceReference.to_prompt_cache`;`analyticsAttemptId` 进 `queue.enqueued`;`canonical` 形状放行时重投影 | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -176,6 +177,30 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem - 前置判据测试:`cancel_direct_codex_turn_at` 在"回合任务泄漏、app-server 侧没有可中断句柄"时仍能解除占用, 并把队首放行出去(`codex_app_server` 的兜底用例 + `direct_stale_turn_for_release` 的单测)。 +## 第 6 步:条目不再另存产物(2026-09-30 修订) + +判据:**在队条目除 `queue.enqueued` 的载荷之外不得有第二个字段**,`prompt` 也不得整条另存。 + +- `agent/direct_codex_user_item/model.rs`:`AgcResourceReference` 增加宿主内存专用的 + `to_prompt_cache: Option`(`#[serde(skip)]` + `#[ts(skip)]`:不序列化、不上线、不进历史、不进绑定), + 存的是这个引用 part 在 prompt 里的片段。 +- `agent/direct_codex_user_item/wire.rs`:prompt 投影拆成两半——入队检查时 `freeze`(校验 + 算片段 + 写盘 + 冻结进 part), + 之后 `direct_codex_user_item_to_prompt(&item)` 是纯折叠(零 IO、零校验、无失败出口)。 +- `agent/direct_thread_wire.rs`:`queue.enqueued` 增加 `analyticsAttemptId?`(发送侧在入队那一刻定的埋点身份)。 +- `agent/direct_thread_queue.rs`:`PendingDirectTurn` 改成 `queue.enqueued` 的**投影**(`from_event` / `enqueued_event` 往返), + 不再有 `canonical_user_item` 与 `prompt`;新增"在队 = 有 enqueued、没有配对 removed"的事件折叠。 +- `agent/direct_thread_manager.rs`:删 `StoredEvent.pending`(`append_inner` 并入 `append`);`pending_turns` / 容量 / + 判重 / `remove_pending_turn` / `claim_pending_turn` 全部改读事件折叠;`mark_queue_events_cleanable` 删掉 + "产物还在就不回收"的防御。 +- `agent/direct_turn_dispatch.rs`:放行的 canonical 形状 = `serde_json::to_value(&pending.user_item)`,prompt = 纯投影, + `analyticsAttemptId` 从事件读。 + +不变式:放行侧不写盘、不读 manifest、不重跑校验;入队检查仍然是这条消息唯一的失败出口。 + +验收(Rust 单测):`PendingDirectTurn` 与 `queue.enqueued` 往返一致;`to_prompt_cache` 不出现在序列化结果里; +队列折叠与原有 `pending_turn_ids` 语义一致(顺序、按身份移除、已放行返回 `AlreadyDispatched`); +同一批 `d341a9be1` / `e0ca5ad9b` 的中途加入 bootstrap 用例仍通过。 + ## 验收与证据 - Rust:第 1、2、5 步各自的单测;`cargo test` 定向 + `cargo check --tests` 无新增告警。