文档:接单化落地收尾,ADR 转已接受并同步下游口径

docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:状态改成已接受并指向实施计划;"落地时要同步的文档与注释"改成已同步清单。
docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md:顶部取代注扩到"事件不带回合身份"与"宿主侧 Drop 守卫兜底"两条决策形状,影响一节的两条已知边界逐条写明新口径。
docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md:四步标记落地并补每步落地结果、验收证据(rust agent:: 949 / 前端 4473)与已知坑。
docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md:正常回合改成接单后先落盘再注入;异常回合收尾写明唯一终态出口是占用对象。
docs/project-memory/shared-memory/decision-log.md:host-dropped 两条口径加取代注(含 kind 追加 environment-not-ready),并追加 2026-09-23 接单化决策一条。
docs/README.md:索引行去掉"未实施",改成四步均已落地。
This commit is contained in:
2026-09-23 22:05:11 +08:00
parent 2772081791
commit ce668bbff3
6 changed files with 114 additions and 59 deletions
@@ -2,65 +2,80 @@
更新时间:`2026-09-23`
状态:**四步全部落地**。
设计口径见 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)。
本文件只排实施顺序、不变式与验收,不重复设计理由。
## 已落地
## 第 0 步:文档与既有缺陷清理(已落地)
- 设计定稿:ADR、`CONTEXT.md` 术语(逻辑回合 / 接单 / 拒单 / 在途回合)、两处旧文档的取代注。
- 前端删除由 invoke 拒绝驱动的认证重试(`directCodexSessionKeepalive.ts` 只留会话保活)。
- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史、
- 用户可见文案不再带 `详情:` 引用、失败进错误上报池、失败说明不再写进项目历史,
只服务详情展开的 IPC `read_agent_runtime_error_detail` 已删除。
## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)
## 第 1 步:Thread Manager 拥有逻辑回合(Rust,一个原子提交)——已落地
改动点:
- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id)` 在
同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)`
- 新模块 `agent/direct_turn_accept.rs`:按 thread 维护占用登记。`accept(thread, user_item_id, client_turn_id)`
在同一个临界区里完成"拒绝并发 + 登记占用 + 追加逻辑回合开始事件";`AcceptedTurn::finish(terminal)`
幂等写出 `turn.completed` 并解除占用;`Drop` 兜底补 `host-dropped` 终态。终态写出后占用才释放。
- `direct_thread_manager.rs`:登记与事件追加必须共用同一把锁(不要再加第二张静态表)。
- 删除 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧
- `direct_thread_manager.rs`:登记与事件追加共用同一把锁(没有第二张静态表)。
- 删除了 `codex_app_server/mod.rs` 里镜像 Codex 原生回合的开始事件与终态追加,以及 app-server 侧
武装的 `DirectTurnFailureGuard`;终态统一交给 `AcceptedTurn::finish`。
- `direct_thread_wire.rs`:`userItemId` 的说明由"从已落盘条目读取"改成"由 `clientTurnId` 推导"。
不变式:线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed` 必带
`userItemId`。
不变式(已验证):线上仍只有一对生命周期事件;同一 thread 任意时刻至多一个占用;`turn.completed`
必带 `userItemId`。
验收:TM 单测(并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单)+
`cargo test agent::direct_runtime agent::direct_thread`。
## 第 2 步:命令改接单 + 后台跑整轮(Rust)
## 第 2 步:命令改接单 + 后台跑整轮(Rust)——已落地
- 顺序固定为:`clientTurnId` 校验 → 占用调用身份 → 工作流恢复 → 用户条目校验 → 工程准备 →
`accept` → 落盘用户条目 → spawn 整轮。
- 接单前的检查从 `run_..._and_emitter` 上移到命令;分流判据改成位置(接单后一律回合失败),
`EnvironmentNotReady` 增加 `wire_kind() = "environment-not-ready"`,"调用级拒绝直通"的分支作废。
- spawn 出的任务在正常 / 失败 / 早退三条路径上都要走 `AcceptedTurn::finish`。
- spawn 出的任务在正常 / 失败 / 早退三条路径上都走 `AcceptedTurn::finish`;任务 panic 或被取消时
由占用对象的 `Drop` 兜底。
- 落盘即接单:接单成功后落盘用户条目,再起 codex;落盘失败仍是接单后的回合失败(有回合事件解释)。
验收:`cargo test agent::direct_runtime`;手工把 app-server 配错,界面应收到
`turn.completed{failed, environment-not-ready}`,而不是只有横幅。
## 第 3 步:拒单返回 typed 错误(Rust + TS)
## 第 3 步:拒单返回 typed 错误(Rust + TS)——已落地
- `DirectTurnError` 加 `Serialize + TS`(含嵌套枚举)并导出到 `chat/generated/`;命令返回
`Result<(), DirectTurnError>`,文案仍由 `Display` 生成一次随载荷带出。
- 前端 catch 按变体分流:认得的前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;
认不得的 → 抛出;状态行只显示回合状态。
- 前端 catch 按变体分流(`readDirectTurnRejection` / `directTurnRejectionNotice`):认得的
前置 / 参数类 → 与用户消息同级的提示、不走 `captureAgentRuntimeError`;认不出的 → 抛出;
状态行只显示回合状态。
- 认可名单:`clientTurnIdMissing` / `clientTurnIdMalformed` / `turnAlreadyRunning` /
`projectRootUnanchored` / `projectRootUnusable` / `permissionRejected` / `inputRejected` /
`contentEmpty`;`environmentNotReady` / `hostStateUnavailable` 返回 `null`(抛出上报)。
- 拒单**不结算埋点**(埋点句柄只清不发)。
验收:`npm run ai-game-creator-shell:typecheck`;手工触发一次拒单(并发 / 空内容)确认提示位置与无上报。
## 第 4 步:队列、埋点、快照、reducer(TS + Rust)——已落地
## 第 4 步:队列、埋点、快照、reducer(TS + Rust)
- 前端队列放行改听"回合完成或拒单":reducer 新增 `completedTurnCount`,作为放行与埋点结算的唯一
判据(不能用 `turnRunning` 的下降沿,一轮可能同批开始 + 结束)。**TODO(已写在代码里)**:这条
队列整体挪到 Rust 端,放行点就是 Thread Manager 的接单动作。
- 埋点结算挂到回合终态事件:句柄活过命令返回,接单成功才在终态结算,接单被拒不结算。
- 首页"运行中的项目"改由 TM 的逻辑回合导出(`list_direct_active_turns`);`DirectActiveTurnSnapshot`
移入 `direct_thread_manager.rs`,`DirectTaonierActiveInvocation` 退回纯单飞锁,不留两处事实。
- 删除取消占位的本地收口 `markTurnStopped()` 与 `turn.started` 的"重复起点保留第一次"兼容分支。
- 本地在途标签(`awaiting-start`)活到宿主认领,认领三判据:`turnUserItemId === pendingUserItemId`
(身份认领)、`currentTurnRunning`、`completedTurnCount > pendingTurnBaselineRef.current`。
- 前端队列放行改听"回合完成或拒单",加 TODO:以后挪到 Rust 端(落点就是接单动作)。
- 埋点结算挂到回合终态事件。
- 首页"运行中的项目"改由 TM 的逻辑回合导出。
- 删除取消占位的本地收口与 `turn.started` 的重复起点兼容分支。
## 验收证据
验收:typecheck;手工连发两条确认第二条不被丢;重进页面忙碌态正确。
- Rust 定向:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"`
(949 passed);TM 单测覆盖并发接单被拒 / finish 幂等 / Drop 兜底 / 收口后可再次接单。
- 前端:`NODE_OPTIONS=--localstorage-file=/tmp/ls-gen.json npm test`(4473 passed)、
`npm run ai-game-creator-shell:typecheck`。
- 仓库门禁:`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
- 手工:连发两条确认第二条不被丢;重进页面忙碌态正确;真实客户端观感未复核。
## 已知坑
- `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。
- 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。
- `list_game_creator_direct_active_turns` 今天读的内存表与单飞锁是同一张,搬迁时别留两处事实。
- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --`
掉不是本次新增的文件。
- 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。
@@ -1,10 +1,12 @@
# DirectProject Codex 原始历史与异常恢复
更新时间:`2026-09-16`
更新时间:`2026-09-23`
> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"、"失败说明不写进
> `project.jsonl`"等结论,已由 [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)
> (草案,未实施)重新决策;实施时需按该 ADR 的"落地时要同步的文档"一节逐句修订本文件。
> 注:本文件里"事件不带回合身份"、"`turn.started` 之前的早退不产生终态事件"这两条结论已被
> [`【ADR】DirectProject命令接单化-2026-09-23`](../adr/【ADR】DirectProject命令接单化-2026-09-23.md)
> 取代并落地:生命周期事件带可选的 `userItemId`,逻辑回合在**接单**时成对发出,接单之前的失败一律
> 是拒单(不产生回合事件)。下文相关段落已按该 ADR 修订;"失败说明不写进 `project.jsonl`"仍是
> 当前口径。
## 目标
@@ -31,13 +33,13 @@ DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会
## 正常回合
1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。
2. 命令**接单**后先写本轮 canonical user item(发送前完成同样的投影校验),再在后台起 codex;新线程把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入,注入成功后执行新的 `turn/start`。这一轮的逻辑回合在接单那一刻就已开始,落盘与注入、`turn/start` 都在回合内,失败由这一轮的终态事件解释(见「异常回合收尾」)。
3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
4. 正常 `turn/completed: completed` 不生成额外记录。
## 异常回合收尾
AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。终态出口只有接单时登记的占用对象一个:正常 / 失败 / 中断 / 取消谁先算出来谁写 `turn.completed`,都写不出时由它的 `Drop` 补 `host-dropped`。
`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
@@ -116,8 +118,8 @@ Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thre
```ts
type DirectThreadEvent =
| { type: 'turn.started' }
| { type: 'turn.completed'; status: string; failure?: { kind: string; message: string } }
| { type: 'turn.started'; at?: number; userItemId?: string }
| { type: 'turn.completed'; status: string; at?: number; userItemId?: string; failure?: { kind: string; message: string } }
| { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem }
| { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string }
@@ -126,13 +128,30 @@ type DirectThreadEvent =
进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。
**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`(失败时另带可选的 `failure`);条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。
**回合身份只挂在生命周期事件上,且由 `clientTurnId` 现算。** DirectProject 同一时刻只有一个回合在跑;
`turn.started` / `turn.completed` 各带一个可选的 `userItemId`(本轮开口用户条目的 canonical id,
`direct-codex:{clientTurnId}:user`),`turn.completed` 另外带 `status` 与失败时必有的 `failure`。
条目、增量、请求与生命周期锚点仍不带 turn id:这个字段只把"这一轮的边界属于哪条用户消息"讲清楚,
不新增一套回合身份,**不读盘回填**(开始事件发生在用户条目落盘之前,落盘本身也可能失败)。前端 state
里的 `turnRunning` 仍是唯一的活动判定,历史条目不记录回合身份;身份缺失时不猜历史归属。
**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `host-dropped`,只给界面选语气),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不再从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。
**终态只有 `turn.completed` 一种,失败靠 `failure` 载荷区分。** `status !== "failed"` 表示正常结束 / 中断 / 终止,事件不带 `failure`;`status === "failed"` 是失败终态,**必须**带 `failure { kind, message }`:`kind` 是稳定分类(`timeout` / `model-failed` / `transport-failed` / `request-rejected` / `environment-not-ready` / `host-dropped`,只给界面选语气,界面不拿它做流程分支),`message` 是脱敏截断后的失败原因。失败原因只走这一条通道——前端不从命令返回或另一条 IPC 里另造失败文案;`status="failed"` 却没有载荷视为协议违规。
**接单之前发生的不是回合失败,是拒单。** 判据是**发生位置**而不是错误种类:目录、权限、输入、
并发、工程准备未就绪这类"接单前就能判定"的失败由命令以结构化的 `DirectTurnError`(ts-rs 导出,
载荷 = 变体 + `Display` 生成的一句文案)返回,不产生任何回合事件、不写用户条目、不写失败诊断;
接单之后的连接、配置、历史注入、`turn/start` 被拒以及回合过程中的一切,都只走 `turn.completed`
带失败载荷这一条通道。`EnvironmentNotReady` 接单前后都可能出现,因此它有自己的失败分类
(`environment-not-ready`),不会被投影成 `model-failed`。
执行通道断开(app-server 进程退出、stdout 流断、回合事件通道关闭)也走同一条终态:`kind="transport-failed"`,`message` 是宿主当场记下的诊断(`exitStatus` + stderr 摘要,脱敏截断)。宿主在检测到连接终止时**第一时间**把这条事实记到本回合的执行适配器上,终态判定再从适配器读——执行适配器的看门狗盯着同一个 `closed` 标志,若只在调用点用局部变量记录,会与看门狗的收束竞争,输掉时就只剩 `status="interrupted"` 加一句收尾说明,界面只显示"本轮已结束"、看不到原因。判据是"适配器是否已由宿主主动关闭":宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)同样会发 `TransportClosed`,但那些不算失败。
宿主的异常收场同样靠这条事件:`turn.started` 进入队列之后武装一个 Drop 守卫,正常写完终态即解除;panic、回合 future 被丢弃、终态之前的早退由守卫补一条 `status="failed"` + `failure.kind="host-dropped"` 的终态。两条已知边界——宿主进程被强杀(`kill -9`)时没有任何 `Drop` 执行;`turn.started` 之前的早退(`turn/start` 请求失败、响应缺 `turn.id`)本身不产生回合——都不产出终态事件,也不假装有回合可收,前端在这两种情况下仍按"命令已返回"的既有语义收尾。
宿主的异常收场同样靠这条事件:**接单**时登记占用对象并发出 `turn.started`,占用对象持有这一轮唯一的
终态出口——正常 / 失败 / 中断 / 取消谁先算出来谁写终态,都写不出时由它的 `Drop` 补一条
`status="failed"` + `failure.kind="host-dropped"`,因此"接单成功 ⇔ 事件流里有开始且有结束"是结构性
成立的,不依赖实现者记得给每条早退路径补事件。唯一的已知边界是宿主进程被强杀(`kill -9`):没有任何
`Drop` 执行,队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态。
接单**之前**的失败根本不产生回合(见上一条:那是拒单),所以不存在"没有事件可解释的回合"。
**终态由事实判定,不由收尾阶段反推。** `turn.completed.status` 不是收尾阶段的口径(`lifecycle_status` 只描述 ledger 阶段,没有终态否决权):判定按「宿主当场记下的失败(通道断开 / 等待超时 / app-server 单方面中断)→ 本回合的错误结果是 Err → 只有账本读不出来时才用交付报告」取原因,有载荷一定写 `status="failed"`。模型自报失败(原生 `turn/completed` 的 `error`,含 `codexErrorInfo`)复用同一条通道:宿主把它投影成 `LlmError` 后当作本回合的错误结果返回,原因文本里带着 `codex-app-server-error:<kind>` 前缀(前端 `projectRuntimeVisibleError` 已有对应中文映射),既不为载荷新增输入字段,也不让交付报告顶掉原因;`RepairRequired`(返修请求)保持自己的原语义。