diff --git a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index ca2f6889a..d3fcc9a62 100644 --- a/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -1,7 +1,8 @@ # 【ADR】DirectProject命令入队化与待发消息队列归宿主 状态:已接受(2026-09-24 设计定稿;**2026-09-30 落地完成**;**2026-09-30 修订**:队列条目不再另存产物, -`prompt` 与 canonical 形状改为放行时从条目重投影,见 §3 / §4 / §5 与「备选方案与取舍」6–8 条。实施顺序与验收见 +引用的解析文本持久化进条目、`prompt` 与 canonical 形状放行时重投影,埋点候选 / settle 两阶段链退役 +(见 §3 / §4 / §5 与「备选方案与取舍」6–9 条)。实施顺序与验收见 [`【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)) ## 背景 @@ -48,9 +49,9 @@ ### 4. 线上形状 -- `queue.enqueued { clientTurnId, userItem, creationType?, analyticsAttemptId?, at }`:`userItem` 是 canonical 用户条目, - 前端据此派生 chip 文案,Rust 不渲染、不裁成展示形状;`analyticsAttemptId` 是发送侧在入队那一刻给这一条消息的埋点身份, - 放行时用它把这一轮的成绩写成埋点候选。事件**不带** prompt——prompt 不是下发形状,放行时由条目重投影(§5)。 +- `queue.enqueued { clientTurnId, userItem, creationType?, at }`:`userItem` 是 canonical 用户条目,前端据此派生 chip 文案, + Rust 不渲染、不裁成展示形状。事件**不带** prompt(prompt 不是下发形状,放行时由条目重投影,§5), + 也不带埋点身份(成绩由宿主自己结算,§5)。队列事件**没有**宿主私有的第二类字段:这一条消息的全部事实就是它自己。 - `queue.removed { clientTurnId, reason }`:`reason` 是 ts-rs 导出的 **typed 枚举**(`cancelled | dispatched`),永不用字符串, 形状与 `turn.completed{status, failure?}` 同构。 - 两条事件与其它运行态事件同一条流、同一个 reducer。 @@ -63,11 +64,16 @@ Thread Manager 在一个回合收口**之后**原子地做:取队首 → 登 **放行不重跑任何检查,也不存在"放行失败"这种状态**:放行之后的一切失败都是**回合失败**,走既有 `turn.completed.failure` 通道; 不新增任何失败通道,也不为放行补拒单出口。 -prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入,其中唯一算不出的片段——引用 UI 设计文档时要展开的代码上下文, -渲染它会往项目里写 `ui/generated-*.js`——在入队检查时冻结到该引用 part 的 `to_prompt_cache` 上。 -这个字段**宿主内存专用**:不序列化、不上线、不进历史、不进 TS 绑定。于是放行侧的 prompt 是条目的纯投影:零 IO、零校验、 +prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入。引用 part 的解析文本(素材摘要 + 引用 UI 设计文档时要展开的 +代码上下文,渲染它要往项目里写 `ui/generated-*.js`)由入队检查写进该 part 自己的 `resolved_text`, +放行、历史回读、turn input 三个读点共用这一份——**历史必须回放出当初那条消息**,所以它是条目事实的一部分、随条目持久化, +不是内存备忘;字段缺省即合法(`serde(default)`),旧历史没有它照样解析、按当前 manifest 现算。于是放行侧的 prompt 是条目的纯投影:零 IO、零校验、 不可失败,与"放行不重跑任何检查"同一个口径。 +埋点成绩同样不靠渲染侧结算:`identity_generation`(`platform_session`:只在登录主体 / 服务 origin / 登出状态变化时推进) +在**放行**那一刻被读一次,回合终态再读一次,变了就整条不记。所以队列事件上不需要任何埋点身份字段, +也不需要"先写候选、等渲染侧确认"的两阶段。 + ### 6. 放行的触发与监护顺序 - 唯一放行点:回合任务收尾之后的 `kick`(正常 / 失败 / 中止三条路径共用,外加一个 drop 守卫盖 panic),幂等,并且在临界区里原子认领队首。 @@ -123,6 +129,9 @@ prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入,其 要写盘的那段代码上下文。改为只把算不出的片段冻结在引用 part 上,整条 prompt 从条目重投影。 8. **用 `clientTurnId` 兼作埋点身份**:埋点候选的 `attempt_id` 必须是 UUID(`analytics/run.rs` 的 `validate`), 而 `clientTurnId` 在 WebView 没有 `crypto.randomUUID` 时会退化成时间戳 + 序号的形状。不选,两个身份各留在自己那一层。 +9. **保留"宿主写候选 + 渲染侧 settle"两阶段埋点**:入队化之后一次发送只有一次尝试(命令只入队、放行由宿主自己做), + "哪一次尝试算数"不再是渲染侧才知道的事;宿主自己就有 `identity_generation`,直接写终态即可。 + 候选表、`settle` 命令与 attempt id 一起退役,成绩也不再依赖"恰好有一个窗口在消费终态事件"。 ## 影响与代价 @@ -143,10 +152,10 @@ prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入,其 两份已接受的 ADR 保留正文与文件名,顶部加词表注记。 - **失去手工验证手段**:CLI 退役同时带走"真实二进制驱动执行层生产验证"这条手工路径(夹具脚本一并退役)。 它不在 CI,损失的是排障时的一次性手段,不是门禁。 -- **埋点**:attempt id 仍由前端在**入队那一刻**生成并随命令交给宿主,宿主把它写进 `queue.enqueued`——它是这一条消息的埋点身份, - 与 `clientTurnId` 一样属于"发送侧在这一刻定下的身份",所以跟着事件走而不是另存条目产物。放行时用它把这一轮的成绩写成埋点候选。 - 前端不再为排队条目**单独持有**一个句柄槽——句柄按 `clientTurnId` 存成一张表,回合终态按本轮开口条目的 canonical 身份认领结算, - 入队失败的那一条直接删掉不结算。 +- **埋点**:成绩由宿主在回合终态直接写,中间不再有候选与 settle:放行那一刻读一次 `identity_generation`、终态再读一次,变了就整条不记。 + 于是前端不再生成、持有或回传任何埋点身份(`analyticsAttemptId` 命令参数、`beginDirectRunAnalytics`、句柄表与 + "入队失败就删掉句柄"一并退役),队列事件也不需要埋点字段。判据口径随放行搬家:从"发送时与终态同代"改成"放行时与终态同代", + 入队后、放行前发生的账号切换因此不再丢弃这一轮的成绩——那一轮确实是在新身份下跑的。 平台会话代际翻转的 `discard` 判定仍是渲染侧职责(结算时必须由渲染侧给出)。 ## 明确不做 @@ -167,5 +176,5 @@ prompt 同样靠**重投影**,不靠另存:`userItem` 是唯一输入,其 `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 一并改。 +- 代码注释:`agent/direct_runtime/user_input.rs` 模块注释里的 CLI 分工一段删掉;`agent/thread_manager/` 的 + `dispatch.rs` / `mod.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 b9c2b96a0..23dc9e0c1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9153,18 +9153,19 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(队列成员):删掉 `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` 时会退化成时间戳 + 序号的形状。 +- 决策(prompt):不另存整条 prompt。引用 part 的解析文本(素材摘要 + 引用 UI 设计文档时要展开的代码上下文, + 后者渲染会往项目里写 `ui/generated-*.js`)由入队检查写进该 part 自己的 `resolved_text`,**随条目持久化**: + 放行、历史回读、turn input 三个读点共用这一份,历史因此回放出当初那条消息(字段缺省合法、旧历史照样解析, + 只是在缺省时才退回按当前 manifest 现算)。投影拆成两半:入队侧的 `freeze`(校验 + 算片段 + 写盘 + 冻结 + 判空) + 与放行侧的纯折叠(零 IO、零校验、无失败出口),于是"放行不重跑任何检查、没有放行失败"这条不变式对新形状仍然成立。 + `canonical_user_item` 同理改为放行时 `serde_json::to_value(user_item)` 重投影。 + 副作用:引用的代码上下文片段不再统一追加在 prompt 末尾,而是跟在它所属的引用片段里,单条引用时逐字节不变。 +- 决策(埋点成绩):`analyticsAttemptId` 那条链整条退役,不换字段存。入队化之后一次发送只有一次尝试, + "哪一次算数"不再是渲染侧才知道的事;宿主在**放行**那一刻读一次 `platform_session` 的 `identity_generation`、 + 回合终态再读一次,变了就整条不记,成绩直接写终态(不再先写候选、等渲染侧 `settle`)。 + 于是 `analyticsAttemptId` 命令参数、候选表 `pending_runs`、`settle_direct_run_analytics`、前端 + `beginDirectRunAnalytics` 与句柄表一起删,`queue.enqueued` 也不需要埋点字段。判据口径随放行搬家: + 从"发送时与终态同代"改成"放行时与终态同代"——入队后、放行前发生的账号切换不再丢弃这一轮的成绩。 - 验证方式(设计稿,尚未实施):ADR §3 / §4 / §5 与「备选方案与取舍」6–8 条、实施计划第 6 步。 ## 2026-09-28 渲染层下沉分支与最新 master 对齐:活动回合事实源、维护态出口与诊断详情 diff --git a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md index e413e4668..e8448b384 100644 --- a/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md +++ b/docs/technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md @@ -2,7 +2,11 @@ 更新时间:`2026-09-24` -状态:**已落地**(第 0–6 步全部完成,2026-09-30) +状态:**第 0–5 步已落地;第 6、7 步进行中**(2026-09-30 修订) + +> 文件路径口径(2026-09-30):本文里 2026-09-24 写的 `direct_thread_manager.rs` / `direct_thread_wire.rs` / +> `direct_thread_queue.rs` / `direct_turn_dispatch.rs` 现在分别是 `agent/thread_manager/{mod,wire,queue,dispatch}.rs`, +> 类型与函数去掉 `Direct` 前缀(`ThreadEvent`、`PendingTurn`、`subscribe_thread` …,见提交 `f121257cc`)。 设计口径见 [`【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24`](../adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md)。 本文件只排实施顺序、不变式与验收,不重复设计理由。 @@ -17,7 +21,8 @@ | 第 3 步 前端收口 | 已落地 | 见下面「第 3 步的落地细则」 | | 第 4 步 CLI 与夹具退役 | 已落地 | 删 `CliCommand::DirectCodexChat`(变体 / `project_path_mut` / 解析 / 派发)、`run_direct_game_creator_turn_at` 一对包装函数与夹具脚本;顺带删掉只剩测试在用的 `direct_turn_error_boundary_text`(判据只剩 `direct_turn_enqueue_failure` 一处),三条边界测试改打 `direct_turn_enqueue_failure(...).message`。**`TurnAlreadyRunning` 挪到第 5 步**(它最后一个生产点在调用身份守卫里) | | 第 5 步 守卫清理 | 已落地 | 五个身份读者改读 `direct_active_turn_id_at`;删 `DirectTaonierActiveInvocationGuard` / `DirectActiveTurnView` / 只读探测与 `release_stale_direct_taonier_active_invocation`(改 `direct_stale_turn_for_release` 只做前置校验);删 `DirectTurnError::TurnAlreadyRunning` 与前端分支 / 生成绑定;补前置判据用例 | -| 第 6 步 条目不再另存产物 | 已落地 | 删 `StoredEvent.pending` 与 `PendingDirectTurn` 的存储形态(改事件投影);`prompt` 片段冻结进 `AgcResourceReference.to_prompt_cache`;`analyticsAttemptId` 进 `queue.enqueued`;`canonical` 形状放行时重投影 | +| 第 6 步 条目不再另存产物 | 进行中 | 删 `StoredEvent.pending` 与 `PendingTurn` 的存储形态(改事件投影);引用解析文本落 `AgcResourceReference.resolved_text`(持久化);`canonical` 与 `prompt` 放行时重投影 | +| 第 7 步 埋点成绩归宿主 | 进行中 | 删候选 / settle 两阶段与 `analyticsAttemptId`(含命令参数、前端句柄表与结算 effect);放行读 `identity_generation`、终态比对后直写 | ## 第 0 步:词表切换(与代码同批,不单独提交) @@ -181,26 +186,44 @@ kick 幂等(并发两次只认领一次);队首在放行后被移除、rem 判据:**在队条目除 `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), +- `agent/direct_codex_user_item/model.rs`:`AgcResourceReference` 增加 `resolved_text: Option`, + 存这个引用 part 被解析出来的文本(素材摘要 + 引用 UI 设计文档时要展开的代码上下文)。它是条目事实的一部分、 + **随条目持久化**:放行、历史回读、turn input 三个读点共用这一份,历史因此回放出当初那条消息; + 字段缺省合法(`#[serde(default)]`,序列化时 `None` 不写字段),旧历史没有它照样解析、只在缺省时按当前 manifest 现算。 +- `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` 往返), +- `agent/thread_manager/queue.rs`:`PendingTurn` 改成 `queue.enqueued` 的**投影**(`from_event` / `enqueued_event` 往返), 不再有 `canonical_user_item` 与 `prompt`;新增"在队 = 有 enqueued、没有配对 removed"的事件折叠。 -- `agent/direct_thread_manager.rs`:删 `StoredEvent.pending`(`append_inner` 并入 `append`);`pending_turns` / 容量 / +- `agent/thread_manager/mod.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` 从事件读。 +- `agent/thread_manager/dispatch.rs`:放行的 canonical 形状 = `serde_json::to_value(&pending.user_item)`,prompt = 纯投影。 不变式:放行侧不写盘、不读 manifest、不重跑校验;入队检查仍然是这条消息唯一的失败出口。 -验收(Rust 单测):`PendingDirectTurn` 与 `queue.enqueued` 往返一致;`to_prompt_cache` 不出现在序列化结果里; -队列折叠与原有 `pending_turn_ids` 语义一致(顺序、按身份移除、已放行返回 `AlreadyDispatched`); +验收(Rust 单测):`PendingTurn` 与 `queue.enqueued` 往返一致;队列折叠与原有 `pending_turn_ids` 语义一致 +(顺序、按身份移除、已放行返回 `AlreadyDispatched`);`resolved_text` 缺省的历史条目仍能解析并投影; 同一批 `d341a9be1` / `e0ca5ad9b` 的中途加入 bootstrap 用例仍通过。 +## 第 7 步:埋点成绩由宿主自己结算(2026-09-30 修订) + +判据:`analyticsAttemptId` 整条链删除,且**不换字段存**。 + +- `agent/direct_runtime/user_input.rs`:入队命令不再收 `analyticsAttemptId`;`agent/thread_manager/wire.rs` 的 + `queue.enqueued` 不加埋点字段(队列事件相对第 5 步零新增字段)。 +- `agent/thread_manager/dispatch.rs`:放行时读一次 `platform_session` 的 `identity_generation`,随放行调用链交给回合。 +- `agent/direct_runtime/mod.rs`:回合终态再读一次,与放行时不同则整条不记;相同则直接写 `Request::Terminal`。 +- `analytics/run.rs`:删 `Request::DirectCandidate` / `Request::Settle` 与 `settle`,`direct_finished` 改收代际并直接落终态。 +- `analytics/store.rs`:删 `pending_runs` / `pending_run_bytes` 与两条候选分支(连带 16 条上限)。 +- `analytics/gui.rs` + `main.rs`:删 `settle_direct_run_analytics` 命令与注册。 +- 渲染层:删 `services/clientAnalytics.ts` 的 `beginDirectRunAnalytics`、控制器的句柄表与结算 effect、 + `TauriInvoke` 里的命令签名;`userItemId` 不再用于认领结算。 + +不变式:成绩不再依赖"恰好有一个窗口在消费终态事件";判据口径随放行搬家——入队后、放行前发生的账号切换不再丢弃成绩。 + +验收(Rust 单测):`analytics::store_tests` 的候选 / settle 用例改为终态直写用例(含"放行后代际变化不记"); +`cargo test` 定向 + 前端 `npx vitest run tests/appSurface.test.ts`、`npm --workspace apps/ai-game-creator-shell run typecheck`。 + ## 验收与证据 - Rust:第 1、2、5 步各自的单测;`cargo test` 定向 + `cargo check --tests` 无新增告警。