重构/对话错误类型化, 避免string-typed #474
Reference in New Issue
Block a user
Delete Branch "feat/fail-as-event"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
- ADR【DirectProject对话历史单一事实源】补三条决策:终态事件只有 turn.completed,失败时 status="failed" 必须带 failure{kind,message};宿主 Drop 守卫在 turn.started 之后武装、写完终态即解除;失败原因只走事件这条通道,聊天说明的展示位保留、数据来源换成事件 - 同 ADR「影响」补两条已知边界(进程被强杀时没有 Drop、turn.started 之前的早退不产回合也不补终态)与「可见文案映射规则不变」的口径 - 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状:turn.completed 增加可选 failure,并写明失败终态与正常终态同权顶替 lifecycle_anchor - decision-log 记本次决策、明确不做项、影响范围与验证方式- direct_thread_wire 新增 DirectTurnFailure{kind,message} 类型,给 TurnCompleted 增可选 failure 字段,并补 turn_completed_failed 构造器与 failure 读取器 - with_user_item_id 显式带上 failure:原先把 TurnCompleted 写成 `..` 会静默吞掉失败载荷,身份与原因必须一起流转 - 新增 wire 用例:失败终态带载荷、正常终态不带且回写不补 null、缺载荷的 failed 事件仍可反序列化 - direct_thread_manager 增回归用例:turn.completed(status=failed) 必须顶替更早的 turn.started 成为 lifecycle_anchor,重放不会把已收口的回合看成"还在跑" - 重新生成 ts-rs 绑定(新增 DirectTurnFailure.ts、DirectThreadEvent.ts 增 failure 字段)并按 prettier 格式化- ADR【DirectProject对话历史单一事实源】补一条决策:连接级故障与回合事件通道关闭同样带 failure{kind:"transport-failed"},原因用宿主当场写下的诊断,判据是"适配器是否已由宿主主动关闭" - ADR「影响」补一条:断开时用户看到的仍是既有映射结果,真实诊断在事件载荷、宿主交付报告与运行日志里,改可见文案属于映射规则变更 - 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状,并写明失败事实为什么必须记在执行适配器上(看门狗会抢时序) - decision-log 记本次决策、判据、不做项、影响范围与验证方式WIP: Feat/fail as eventto WIP: 重构/对话错误类型化, 避免string-typed- 新增 `agent/direct_turn_error.rs`:`DirectTurnError` 每个变体自带字段(调用级拒绝与回合级失败不共用结构和判据),分流只认 `is_turn_failure()`,不再有 `kind` 字段 + 共用字段的伪结构化 - 分类判据从"对原因文本做子串匹配"改成 `match` typed 值:`DirectCodexNativeKind` 只解析 `codex-app-server-error:<kind>` 结构化前缀,原 `direct_turn_failure_kind` / 各 `contains` 词表判据删除 - `direct_runtime`:`run_direct_game_creator_turn_*` 返回 typed 错误;本地 `DirectCodexFailureStage` / `DirectCodexTurnFailure` 与并发前缀常量改由 typed 模型提供;调用级拒绝不进失败诊断、不发 `failed` 事件 - `codex_app_server`:执行适配器把宿主亲见的收场事实(通道断开 / 超时 / 中断)存成 typed 值;模型自报失败经 `DirectTurnError::from_model_call` 投影 - `direct_turn_failure`:终态判定收 typed 错误并投影出载荷 `kind` / `message`;删除 `DIRECT_TURN_FAILURE_{TRANSPORT,INTERRUPTED,TIMEOUT}_KIND` 与 `direct_turn_failure_kind` - `direct_delivery` 返修控制流改用 `ReviewRequired`(不是失败);命令边界与 CLI 仍是 `Result<String, String>`,字符串只在 `Display` 一处生成,`wire_kind` 取值与可见文案与改造前逐一相同- `ProjectRootUnanchored` 从 `is_reportable()` 拿掉,与 `ProjectRootUnusable` 同类:符号链接 / 权限 / 目录被删都是用户自己就能修的文件系统事实,留痕只会变成噪声 - 它不再被 `direct_turn_rejection` 覆写成 `direct-codex-failure:v2 …` 诊断文案,界面按 `Display` 显示「无法锚定 Direct 调用项目目录:{cause}」,两侧对同一变体的分类不再自相矛盾 - 同步 `only_host_and_environment_rejections_are_reportable` 用例与 `is_reportable` 的文档注释9ecb084b6宿主:失败载荷的 kind 改成 typed 枚举(第 1 条)bd78a91e5宿主:并发拒单的两个身份改成回合身份(第 8 条)5a0f3b803宿主:目录锚不定的拒单不再写诊断(第 9 条)b42966eb9前端:Direct 失败说明补上宿主事实句的文案模式(第 7 条)85d69a29a前端:认不出的拒单也在聊天里补一条同级提示(第 11 条)ab970b9fd宿主:连接死亡的失败事实先于看门狗落地(第 6 条,按你选的 ①)cd5feac5f宿主:接单之后的落盘失败不再从命令返回 Err(第 10 条,按你选的「接单成立」)2f5e0b0ed文档:接单化 review 收口第二轮写进 ADR、实施计划与共享记忆8ff155e14文档:接单化 review 收口第二轮的剩余两条写进 ADR、实施计划与共享记忆2ce96a73f(第 3 条)、6cee61b97(第 4 条)、7941aaa66(第 5 条)、2331f62f1(第 2 条)验证:Rust
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins "agent::"(902 passed / 5 ignored,含本轮新增的看门狗回归用例)、定向--bins "agent::direct_turn_error"(15 passed)、cargo fmt;前端npx vitest run apps/ai-game-creator-shell/tests/{appSurface.test.ts,directRunAnalytics.test.ts,directProjectTurn.test.tsx,agentRuntimeModel.test.ts,directThreadChat.test.ts}(appSurface.test.ts222 tests / 9 skipped,另四份 65 passed,合计 278 passed / 9 skipped)、npm --prefix apps/ai-game-creator-shell run typecheck、npm run check:encoding(5069 files)、npm run check:doc-index、git diff --check。坑:
cargo test会跑export_bindings并重写全部chat/generated/(引号风格漂移),跑完必须git checkout --掉不是本次新增 / 改动过的那些文件,否则git diff --check会报 trailing whitespace。对齐的 ADR / 决策(读过的权威口径):
docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md:命令 = 接单 / 拒单(接单前的失败不产生回合事件、不写用户条目、不写诊断);逻辑回合由 Thread Manager 拥有且turn.started/turn.completed结构性成对;占用对象是唯一终态出口且幂等(正常 / 失败 / 中断 / 取消 / 连接断开谁先到谁写);终态的写点在整轮真正结束之后;回合身份由clientTurnId推导(§5);失败原因只走turn.completed.failure(§6/§7)。docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md:project.jsonl就是这条对话的历史,回合失败原因本轮不落历史。docs/project-memory/shared-memory/decision-log.md2026-09-24 条目:不改线上载荷形状(仍是{kind, message})、失败说明条目的已知边界只补注释;本轮追加了同日第二条(kind收成 typed 枚举、拒单身份、可留痕判据、提示口径与两条待决策)。1.
DirectTurnFailure.kind仍是裸string,且两份取值名单都漏了turn-interrupted→ 已修9ecb084b6direct_thread_wire.rs的载荷是DirectTurnFailure { kind: String, message: String },由DirectTurnTerminal::failed用failure.wire_kind().unwrap_or("model-failed")投影;ts-rs 导出成kind: string。wire_kind()实际有 7 个取值(timeout / model-failed / transport-failed / request-rejected / environment-not-ready / turn-interrupted / host-dropped),而生成文件与 Rust doc 注释里的名单只有 6 个。failure.kind分支(rg "failure\.kind"只命中生成文件与测试),它只是「给界面选语气」的标签。DirectTurnFailureKind(Serialize + Deserialize + TS、kebab-case、7 个变体),wire_kind()返回Option<DirectTurnFailureKind>,host_dropped()与DirectTurnFailure::new同步改签名,重新导出generated/DirectTurnFailureKind.ts并把两处 doc 名单补全。线上形状与取值一个都没变(仍是{kind, message}、仍是那 7 个字符串),所以决策记录里「不改线上载荷形状」没有被破例;新增用例failure_kind_wire_values_are_stable钉住取值。2. 测试 spy 没有 mock 实现(
appSurface/chat-composer.suite.ts,登录态那条)→ 已修2331f62f1vi.spyOn(platformSession, 'requestPlatformSessionRefresh')只包了一层、没有桩实现;真回归时测试会先跑真实刷新(网络 / 会话副作用)再断言not.toHaveBeenCalled()。mockResolvedValue({ status: 'stale' })。注意 review 给的建议值{ status: 'unavailable' }不是现役取值——PlatformSessionRefreshResult只有refreshed / stale / failed。3.
directTurnRejectionNotice的空文案返回''而不是null→ 已修2ce96a73freturn rejection.message.trim();,宿主文案为空白时返回空串,与函数标注的string | null契约不符。return rejection.message.trim() || null;。补充实测结论:review 说「会静默掉到通用横幅」不准——调用方现在是if (notice),空串本来就落到同一条横幅分支,所以这次没有用户可见行为变化,只是把契约改对。4. 失败分支里的嵌套三元(控制器 catch)→ 已修
6cee61b97rejection ? … : error instanceof Error ? … : String(error)三层套。rejection ? 拒单映射 : 运行错误映射,仍没有嵌套三元)。5. 埋点句柄对所有错误都先清掉 → 已修
7941aaa66catch一进来就把pendingRunAnalyticsRef匹配clientTurnId的那一项清空,之后才读结构化拒单。turn.completed到达时settlePendingRunAnalytics()只能空转,宿主侧这一轮的候选永远没人结算。readDirectTurnRejection(error),只有结构化拒单(「这一轮没接单」)才清句柄;非结构化错误留着等终态结算。同提交里同步了chat-composer.suite.ts里那句已经过时的注释。6. 「失败事实先于连接收束」的时序保证不成立(
codex_app_server/mod.rsvsexecution.rs)→ 已修ab970b9fd(你问"哪个修法让代码库更好":① 是唯一能给出"事实先于可见性"的修法,已按 ① 落地)fail_game_creator_codex_app_server_connection(stdout EOF / JSON-RPC 无效 / stderr 读失败 / stderr 单条超限都调它)第一步就是inner.closed.swap(true, Ordering::AcqRel)——它同时干两件事:去重(第二个调用者直接 return)和把"连接已死"暴露给看门狗。child.lock().await+try_wait()、stderr_summary.lock().await、app_log!,最后record_execution_turn_failure→adapter.fail_turn(TransportClosed{…})。这三步里有两个.await加锁和一个日志写,都可能让出线程。ExecutionAdapter::start_watchdog)每一轮先判!adapter.closed && inner.closed就interrupt("执行连接已结束,正在核对自有子进程与在途操作。"),紧接着(同一轮迭代)session.tick()看到阶段已经是终态就shutdown_and_report:置adapter.closed+background_done、回收连接、发 outcome。fail_turn的记入判据是!self.is_closed() && !self.host_stop_requested(),is_closed()读的正是适配器自己那个被看门狗置上的closed。TransportClosed就记不进去,那一轮退化成「本轮已结束、没有原因」。a. 还有第二个记入点:collect 循环收到
CodexTurnEvent::TransportClosed(或事件通道无终态关闭)时也会fail_turn,外面还套了一层!adapter.is_host_ending()。看门狗先动手时这两条判据同样都已经为真,所以这个记入点也救不回来。b. 失败事实是"快照"读的:
mod.rs里在模型终态那一刻就把adapter.turn_failure()拷进DirectTurnTerminalContext(不是终态写出时才读),所以"晚一点补记"没有用——必须保证事实在看门狗可见之前就已经落地。c. 窗口量级:
inner.closed置位到record_execution_turn_failure之间是"两次无争用加锁 + 一次日志写"(微秒级),而看门狗最长要等 200ms,但changed/notified也会唤醒它,且 stderr 读任务持stderr_summary锁(observe写摘要时)、app_log!的 sink 阻塞时窗口会被拉长——所以窗口窄但真实存在。d. 现有 Rust 用例(
host_observed_failure_is_recorded_with_its_kind_and_reason、host_ended_turn_is_not_a_failure)都是直接调fail_turn,没有把看门狗跑起来,所以这条不变量目前只活在注释里。CodexAppServerInner加一个只服务去重的私有AtomicBool(例如connection_failure_claimed),fail_game_creator_codex_app_server_connection用它去重,取消这里对inner.closed的置位,让shutdown_game_creator_codex_app_server_inner里那一次(它已经存在)在record_execution_turn_failure之后把标志置上。于是看门狗在事实落地前没有任何可观测信号,竞态从根上消失。代价与注意:① 其它读inner.closed的地方(mod.rs:2966、mod.rs:4559、连接池淘汰mod.rs:2417)会晚几十微秒看到"连接已死";② 两个并发的死亡观察者会各自走到shutdown_game_creator_codex_app_server_inner(它本身是幂等的:child 已被取走时读已记录的退出证明),但"只记一次事实"的语义要改由新的私有标志承担;③ 必须补一条把看门狗真正跑起来的用例(断言turn_failure()非空、终态带transport-failed载荷),否则这条不变式还是只有注释。is_closed()):新增一个只给"连接死亡"用的入口(如fail_turn_from_connection_end),判据收到!host_stop_requested(),调用方再叠加"这次死亡不是宿主挑起的"。要点与两个风险:① stdout EOF 与 stderr 读失败走的是同一个函数,而宿主自己收尾时顺序是"先置adapter.closed→ 再关连接 → stdout 才 EOF",所以拿掉is_closed()后必须换一个判据,否则正常终态 / 预算封口会被回写成transport-failed(host_ended_turn_is_not_a_failure会红);② 剩下唯一能用的判据是阶段(is_host_ending()),可看门狗在竞态里同一轮已经把阶段推成Interrupted,所以 ② 只能收窄窗口、堵不住它。要真做 ②,得把"谁挑起的收尾"变成显式 provenance(谁置的标志写在自己的字段上),而不是复用is_closed()/ 阶段这两个都会被看门狗翻的判据。ab970b9fd):CodexAppServerInner新增私有的connection_end_claimed只做去重;fail_game_creator_codex_app_server_connection不再置inner.closed,closed交给shutdown_game_creator_codex_app_server_inner在record_execution_turn_failure之后置位。于是看门狗在事实落地前拿不到任何可观测信号,窗口从根上消失;closed的字段注释改成"这一段已经收束 / 失败事实已经记下",明说它不能再兼作去重标志,收口路径里也补了顺序不变式的注释。connection_death_records_the_failure_fact_before_the_watchdog_seals_the_turn,#[cfg(unix)]):假 app-server 起真连接 + 真DirectProject工作区,turn/start应答后锁住stderr_summary(收口路径停在"记事实"之前),再放子进程退出、睡 500ms(≥ 看门狗 200ms 周期)后放锁,断言恰好一条终态是failed+transport-failed载荷、message 含「已退出」。两个方向都验过:现在的代码绿,把去重改回inner.closed.swap(...)立刻红(left: "interrupted", right: "failed")。is_closed()与阶段都会被看门狗翻,要真做必须先拆出"谁挑起的收尾"的 provenance,改动面比 ① 大且只能收窄窗口,所以只作为备选留档。7. 失败说明条目的文案映射会吃掉宿主准备好的原因 → 已修
b42966eb9(含一处 review 没提的口径 bug)turn.completed.failure.message由宿主脱敏 + 截断(600 字上限),前端directTurnFailureNoticeText整条丢进projectRuntimeVisibleError(raw, '陶泥儿智能创作', true);那个函数只认它自己的模式 / 白名单表,未命中就兜底成「陶泥儿智能创作 执行失败,请稍后重试」。落盘失败 / 写入失败,也不含路径或kind=,用户只剩一句通用文案;TransportClosed的「执行通道已断开,不能自动重放未确认操作:…」同理(它连connect/transport这类关键字都没有)。review 建议的「没命中就回落宿主原文」不能照做:原文里带exitStatus=/stderrClass=这类内部字段,正是靠不回落才没进聊天。directCodexDiagnosticFailureDetail只匹配direct-codex-failure:v1,而宿主发的是多一段code=的v2(git log -S 'v2 stage='→ 自 #376 起就是 v2),所以那条"可读诊断摘要"路径从来没命中过,横幅对可留痕的拒单一直是通用文案。Display逐个加模式——执行通道已断开→ 「服务连接已断开,请稍后重试」、等待模型回合结束达到硬上限→ 「响应超时,请稍后重试」、宿主任务提前结束→ 「本轮执行已中断,请重试」、落盘那一档补收尾历史失败 / 未确认历史完整落盘 / 写入本项目对话历史失败(这一档 review 的举例正好命中);② v1/v2 都认(把解析拆成DirectDiagnosticParts,projectRuntimeVisibleRejectionError复用同一份解析);③ 把「不加模式就只会看到通用文案」写成directTurnFailure.ts的注释,并注明加模式要补agentRuntimeModel.test.ts用例;④ 用例:新增 v2 收口文案、拒单文案、三句宿主事实句,directThreadChat.test.ts里那条断言旧通用文案的期望改成映射后的句子。TimedOut的空闲上限那句(「等待模型执行回执超时…」)本来就含"超时"→ 命中已有分支,所以只给硬上限那句加了模式;另外「无法锚定 Direct 调用项目目录:拒绝访问」会被映射里的「拒绝」子串分支认领成「被项目权限或安全策略阻止」,但目录锚不定的拒单在聊天里走Display原样显示、且不在上报名单里,摸不到这句(我在用例里把它写成了已知边界)。8.
TurnAlreadyRunning的身份字段在占用登记这条路径上是进程内 UUID → 已修bd78a91e5direct_thread_manager.rs:205-236的accept_turn冲突时返回active.token.clone()(进程内 UUID);direct_turn_accept.rs:44-51把existing_invocation_id = existing、incoming_invocation_id = token.clone()(另一个新 UUID)塞进TurnAlreadyRunning。另一个产出点用的是真实clientTurnId。Display里existing == incoming的「同一轮消息仍在处理中」分支在这条路径上永远不可能命中;② 前端拿到的两个身份不是clientTurnId,与「回合身份由clientTurnId推导」和字段注释冲突。clientTurnId)先于占用登记执行,且两个 guard 同生共死,所以并发重发实际撞的是前者、身份是对的;这条 UUID 路径基本到不了,但它是一处真实的不一致。accept_turn冲突时返回active.turn_id,DirectTurnReservation::accept把client_turn_id传成incoming_invocation_id。占用对象自己的token仍是 UUID(complete_direct_thread_turn_if_reserved靠它配对),只换了错误载荷里的两项;新增两条用例(占用登记侧、Thread Manager 侧各一条)。9.
ProjectRootUnanchored归「可上报」,但前端把它当「用户能自己改」,于是用户看到原始诊断串 → 已修5a0f3b803(按你选的 ①)is_reportable()把ProjectRootUnanchored标成true;边界direct_turn_rejection对可上报变体用record_direct_codex_failure(...)的返回值覆盖message,那份文本是direct-codex-failure:v2 stage=… code=… retryable=… summary=…;建议:…;已保存脱敏项目诊断;前端directTurnRejectionNotice把projectRootUnanchored列进「认得」名单并原样显示message。Display的「无法锚定 Direct 调用项目目录:{cause}」;两侧对同一变体的分类自相矛盾。is_reportable()拿掉,与ProjectRootUnusable同类——用户自己就能修的文件系统事实;现在它按Display显示,也不再进.agent/runtime/errors。可留痕的拒单只剩environmentNotReady/hostStateUnavailable。代价是这类事实的留痕变少(你已确认接受)。10. 接单之后的落盘失败仍从命令返回
Err,同一个失败下发两次 → 已修cd5feac5f(你说「接单成立」:落盘失败仍然写失败终态,但命令不再回Err)direct_runtime/user_input.rs里DirectTurnReservation::accept已经发出turn.started之后,append_direct_project_user_message_at失败会先reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))(写出turn.completed status=failed+EnvironmentNotReady载荷),然后return Err(EnvironmentNotReady{ detail: "写入本项目对话历史失败:…" })。turn.completed,不再回到返回值上」);② 同一个失败经两条通道下发:事件在聊天里写一条失败说明,命令Err又给横幅,且EnvironmentNotReady可上报 → 边界再写一份诊断 + 上报池(前端 catch 也captureAgentRuntimeError,只靠 fingerprint 合并);③ 前端模型是「Err= 拒单、这一轮没开始」,但这里turn.started已经发过,于是忙态与出队会同时被"事件驱动"和"命令返回驱动"两条路推。project.jsonl是这条对话的单一事实源(见「对话历史单一事实源」ADR):这一轮的用户消息没进历史,下一轮的注入就缺这条消息,而继续跑出来的回复会正常落盘 → 历史里出现「没有开口用户消息的助手回复」;而且失败被静默,用户以为在跑。所以接单后的落盘失败仍然是这一轮的失败,必须留下解释,只是解释不该走命令返回值。Err下发这条"落盘失败"?不要,建议改成Ok(())。finish_if_unfinished已经写完终态,占用对象的Drop兜底自然变成空操作;前端Ok只表示"接单成立",忙态 / 出队 / 埋点结算都由那条已经入队的事件驱动(订阅会补发),于是同一失败只出现一次、也不会再把"已经开始的回合"读成"没开始"。cd5feac5f):chat_with_game_creator_direct_codex_typed在接单后的历史追加写失败时仍然reservation.finish_if_unfinished(DirectTurnTerminal::failed(...))(事件流里那条失败说明就是界面唯一一份解释),但改成return Ok(())——命令的Err只表示拒单;注释里写明"接单后的一切失败由占用对象收口"以及"不继续起整轮"的理由(历史是这条对话的单一事实源)。a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting借.agent/runtime/test-fail-next-direct-project-history-append注入把两次追加写都判成争用失败,断言恰好一条失败终态、message 含「写入本项目对话历史失败」且不含direct-codex-failure(拒单收口文案不许出现在回合失败里)、占用已释放(下一轮还能接单);前端appSurface/chat-composer.suite.ts的落盘失败用例断言说明恰好一条、忙态放掉、下一条能直接发出去。Err:那要一起改三处——ADR 的分工描述改成两条通道并存、前端对environmentNotReady拒单不再写横幅、以及"命令Err但turn.started已发"下由谁驱动队列与忙态的说明。成本比Ok(())高,未采用。11. 认不出的拒单只留横幅,乐观用户气泡永远没有解释 → 已修
85d69a29a(按你选的 ①)environmentNotReady/hostStateUnavailable)与其它非结构化错误共用「上报 + 横幅」通道,聊天里什么都不写;注释还写着「宿主已经把它放进了turn.completed.failure,reducer 会把它落成本轮最后一条条目」。turn.completed(direct_turn_error.rs的拒单 / 失败分层就是这条判据),所以那条乐观用户气泡后面永远没有说明,只剩一条会消失的横幅;注释对拒单不成立。这是删掉本地说明条目时留下的洞(当时的"失败说明唯一来源是事件"只对回合失败成立)。directTurnUnrecognizedRejectionNoticeText,控制器在这条分支补写一条与用户消息同级的提示(沿用directTurnRejectionNoticeMessageId身份);文案走第 7 条那份映射的拒单档——取宿主收口文案里已脱敏的summary;建议:…,不套阶段标签(拒单这一轮没有开始,阶段只会是默认值code-generation,套上去会把没发生的事讲成发生了),机器字段(direct-codex-failure/stage=/code=)不进聊天。上报与横幅照旧保留(一个是给用户看的话,一个是把现场送进上报池 /.agent/runtime/errors)。非结构化错误仍只走横幅——它可能发生在接单之后,说明由事件流负责。chat-composer.suite.ts补一条结构化拒单的界面用例(同级提示可见、direct-codex-failure/stage=不进聊天、忙碌态放掉、能直接重发);project-conversation.suite.ts补文案函数的单元断言(含"不是收口形状时只给通用兜底")。- 本轮开口用户条目(item_completed,direct-codex:{clientTurnId}:user)原来在 app-server turn/start 应答之后才下发;接单到 turn/start 之间的失败(连不上 app-server、执行器未通过验收、历史注入失败)走不到那一步,事件流里只有逻辑回合的一对事件,没有开口条目 - 把那段内联下发抽成 emit_direct_thread_user_item,发点提前到「接单成立、用户条目落盘成功、起 codex 之前」(direct_runtime/user_input.rs 的命令主体),并删掉 turn/start 之后那一处:线上仍只有一处下发,不变式变成「接单 → 开口用户条目 → 整轮里其余一切」 - 新增回归用例 the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn:断言行首两条事件是带身份的 turn.started 与开口用户条目,终态只能在它们之后 - 回显过滤用例补上同一发点的模拟步骤(生产入口的两个动作:落盘 + 下发)