From 4415c7a5ff99464c8118e1441aee95927f33aa09 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:23:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E6=94=B6=E5=B0=BE=E8=A2=AB=E8=AE=B0=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E8=BD=AE=E6=9C=AA=E5=AE=8C=E6=88=90=EF=BC=88CLI=20?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E4=B8=8D=E5=86=8D=E6=98=AF=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=96=AD=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fail_turn:宿主自己关的连接(is_closed)只把原因追加进终态说明,不再调 session.interrupt,避免把正常收尾改写成 interrupted - direct_execution:新增 ExecutionSession::append_terminal_note,与 interrupt 同款留痕但不改阶段;用户主动终止与真实通道故障保持原口径 - 复验:真 AGC CLI → 真 app-server → loopback Responses 夹具 6/6 passed(原来 6/6 失败);completed 用例 phase=completed、executorStopped=true,terminalReport 为宿主真实交付报告 - 定向单测 codex_app_server::execution 13 passed(含 host_ended_turn_is_not_a_failure、user_requested_stop_is_not_recorded_as_a_failure)、direct_execution 26 passed - 文档:pitfalls 记录 Killer 定位与修复、decision-log 记该决策与边界、DirectProject 里程碑说明该夹具现为可复跑证路(两条 UI 验收仍需真实客户端窗口) --- .../src/agent/codex_app_server/execution.rs | 12 +++++++++++- .../src-tauri/src/agent/direct_execution.rs | 19 +++++++++++++++++++ ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 5 ++++- .../shared-memory/decision-log.md | 8 ++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 +++++--- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 84473382d..f38b93f13 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -747,7 +747,17 @@ impl ExecutionAdapter { /// 不得覆盖它。 pub(super) async fn fail_turn(&self, failure: DirectTurnError) { let reason = failure.to_string(); - if !self.is_closed() && !self.host_stop_requested() { + if self.is_closed() { + // 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。 + // 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted` + // 并把回执文案换成「执行通道已断开」(见 pitfalls 2026-09-28)。 + let session = Arc::clone(&self.session); + let note = reason.clone(); + let _ = tokio::task::spawn_blocking(move || session.append_terminal_note(note)).await; + return; + } + // 走到这里说明不是宿主自己关的连接;用户主动终止仍按原口径(记报告、不改失败事实)。 + if !self.host_stop_requested() { if let Ok(mut slot) = self.turn_failure.lock() { if slot.is_none() { *slot = Some(failure); 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..6ca019902 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 @@ -1293,6 +1293,25 @@ impl ExecutionSession { ); self.commit(&mut data, next) } + + /// 只把原因追加进终态说明,**不改阶段**。 + /// + /// 宿主自己收尾(正常终态、预算与交付收尾)时会先关掉 app-server,连接随之关闭; + /// 这类「关闭原因」要留痕给排障看,但不能把已经/正在正常收口的回合改写成 `Interrupted` + /// —— CLI、单回合宿主每轮都会命中这个窗口(见 pitfalls 2026-09-28)。 + pub(super) fn append_terminal_note(&self, reason: String) -> Result<(), String> { + let mut data = self.lock()?; + let mut next = data.ledger.clone(); + let prior = next.terminal_report.take().unwrap_or_default(); + next.terminal_report = Some( + format!("{prior}\n{reason}") + .trim() + .chars() + .take(16_000) + .collect(), + ); + self.commit(&mut data, next) + } } #[cfg(test)] diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index a0a2bd692..13a6e9968 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -81,4 +81,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - **缺陷**:全新项目的第一次对话直接失败。夹具 `--cases completed` 下 AGC 进程 4 秒退出、`requests=[]`,stderr 只有 `Codex app-server JSON-RPC 失败:items must not be empty`。根因是 `thread_created` 时无条件发 `thread/inject_items`,而空项目没有 `.agent/conversations/project.jsonl`,载荷成了 `items: []`;codex app-server 0.155.1 起把空数组当协议错误。GUI 路径没暴露是因为前端会先写用户消息,CLI / 无前端宿主是裸的。 - **修复**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入;新增单测「空历史不得构造载荷」「有历史仍构造 1 条」。`cargo test … direct_project_history_wire`(3 passed)与 `cargo fmt --check` 通过。 - **复验**:同一条命令下夹具从「4 秒失败」变成「走到 loopback Provider:`requests=3`、`markers.ready=true`、AGC exit=0」,说明第一层已经修通。 -- **仍未完成(第二层)——已定位到 Killer**:修好第一层后夹具的**全部 6 个用例**都走到 loopback Provider(`requests=3`、`markers.ready=true`、exit=0),但统一失败在账本阶段(`phase=interrupted`,期望 `completed`/`exhausted`;CLI 回执是「执行通道已断开,不能自动重放未确认操作」)。排查结论:① 不是 codex 版本漂移(换 0.147.0 相同);② 不是 stdin 被忽略(改 `pipe` 相同);③ 不是 `Drop for ExecutionBinding`(插桩后一次没打印);④ app-server 不是崩溃(1242 字节 stderr 全是 ProgramData/模型元数据/PowerShell snapshot 之类 WARN);⑤ **是宿主自己关的**——关闭原因日志显示 `reason=宿主执行预算或交付收尾`,来自 `codex_app_server/execution.rs:938` 的 `shutdown_and_report()`:它先置 `closed` 再关 app-server,而关进程会给在途回合通道发 `TransportClosed`,于是等待中的 CLI 回合拿到"连接断开"、账本落 `interrupted`。修复方向:宿主主动收尾时先交付回合结果、不向该回合发 `TransportClosed`(GUI 常驻 app-server,不走这条路径,影响面是 CLI / 单回合宿主与依赖它的夹具)。**两条运行时验收本质上要真实客户端窗口**(夹具只能证历史持久化,证不了「界面不显示忙碌态」),因此继续未勾选;除关闭原因日志(留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下)外,临时插桩已全部回滚。 +- **第二层已修:CLI / 单回合宿主的收尾不再被记成本轮未完成**。定位过程(都靠插桩,不是推测):① 不是 codex 版本漂移(换 0.147.0 相同);② 不是 stdin 被忽略(改 `pipe` 相同);③ 不是 `Drop for ExecutionBinding`(插桩后一次没打印);④ app-server 不是崩溃(1242 字节 stderr 全是 ProgramData/模型元数据/PowerShell snapshot 之类 WARN);⑤ **是宿主自己关的**——关闭原因日志显示 `reason=宿主执行预算或交付收尾`(`execution.rs:938` 的 `shutdown_and_report()` 先置 `closed` 再关 app-server,关进程会给在途回合发 `TransportClosed`)。消费端(`mod.rs:4015`)只看 session 阶段、没看 `closed`,而 `fail_turn()` 内部无论如何都会 `session.interrupt(reason)`,于是正常收尾被改写成 `interrupted`、回执换成「执行通道已断开」。 + 修复:`fail_turn()` 在 `is_closed()` 时只把原因追加进终态说明(新增 `ExecutionSession::append_terminal_note()`),**不改阶段**;用户主动终止与真实通道故障保持原口径。 + **复验(本里程碑现在有了可复跑的真机证路)**:`node apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs --agc-exe apps/ai-game-creator-shell/src-tauri/target/debug/genarrative-ai-game-creator-shell.exe --cases completed,passes,mcp,mcp-write,native,deadline` → **6/6 passed**(真 AGC CLI → 真 app-server → loopback Responses,无账号无付费);`completed` 的账本 `phase=completed`、`executorStopped=true`,`terminalReport` 变成宿主真实交付报告。定向单测 `codex_app_server::execution` 13 passed、`direct_execution` 26 passed。 +- **两条运行时验收仍继续未勾选**:它们的判据里有「界面不显示忙碌态」「页面重进(含切走再切回)能恢复运行中回合并允许终止」,本质要真实客户端窗口;上面的夹具只能证「真实 app-server 下的回合收尾与历史持久化」,证不了 UI 投影。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2adb0fe73..a00a5a476 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9718,6 +9718,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入(空历史本来就没有可注入内容);有历史时行为不变(仍按原载荷、原大小前置校验)。 - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_project_history_wire` → 3 passed(新增「空历史不得构造载荷」「有历史仍构造 1 条」);同一条夹具命令从「4 秒失败、`requests=[]`」变成「`requests=3`、`markers.ready=true`、AGC exit=0」——但账本仍落 `interrupted`(app-server 收尾连接终止,`stderrBytes=1242`),是另一层问题,未在本次一并修。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{direct_project_history_wire.rs,mod.rs}`、`docs/project-memory/shared-memory/pitfalls.md`、DirectProject 里程碑。 + +## 2026-09-28 宿主自己关掉的连接不能把回合改写成 `interrupted` + +- 背景:修掉 DirectProject 空历史注入后,真机夹具(真 AGC CLI → 真 app-server → loopback Responses)6/6 都走到 Provider,但仍统一失败在账本阶段:`phase=interrupted`、CLI 回执是「执行通道已断开,不能自动重放未确认操作」。插桩排除 codex 版本漂移、stdin、`Drop for ExecutionBinding`、app-server 崩溃之后,关闭原因日志给出结论:`reason=宿主执行预算或交付收尾`,来自 `shutdown_and_report()`(`execution.rs:938`)——宿主收尾先置 `closed` 再关 app-server,关进程会给在途回合发 `TransportClosed`;消费端(`mod.rs:4015`)只看 session 阶段、没看 `closed`,而 `fail_turn()` 内部无论如何都调 `session.interrupt(reason)`,于是把正常收尾改写成了失败。 +- 决策:`fail_turn()` 在 `is_closed()`(宿主自己收尾:正常终态、预算与交付收尾)时**只把原因追加进终态说明、不改阶段**,新增 `ExecutionSession::append_terminal_note()`(与 `interrupt()` 同款留痕,但不设 `Interrupted`);用户主动终止(`host_stop_requested`)与真实通道故障仍按原口径记失败并中断。判据用标志而不是"事件到达先后",与既有 `fail_turn` 守卫保持一致。 +- 验证:`node apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs --agc-exe --cases completed,passes,mcp,mcp-write,native,deadline` → **6/6 passed**;`completed` 用例账本 `phase=completed`、`executorStopped=true`,`terminalReport` 为宿主真实交付报告(不再是通道断开文案)。`cargo test … codex_app_server::execution` 13 passed(含 `host_ended_turn_is_not_a_failure`、`user_requested_stop_is_not_recorded_as_a_failure`)、`cargo test … direct_execution` 26 passed、`cargo fmt --check`、`cargo build` 通过。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{direct_execution.rs,codex_app_server/execution.rs,codex_app_server/mod.rs}`、pitfalls、DirectProject 里程碑。GUI 常驻 app-server 不走这条每轮关闭路径,受影响的是 CLI / 单回合宿主(`--direct-codex-chat` 及依赖它的夹具)。 +- 边界:这修的是"宿主收尾被记成失败",不改变两条 DirectProject 运行时验收仍需真实客户端窗口(UI 忙碌态与页面重进)。 - 决策(口径回归):macOS 现行契约是 arm64 单架构(2026-09-21 决策),里程碑里「两个 macOS 平台键指向 universal 产物」的旧文字按现行决策改写;不得据此重新切回 universal,除非按该决策给出的恢复路径补齐按架构的 Node 运行时。 - 影响范围:`apps/ai-game-creator-shell/scripts/build-macos-ci.mjs`、新增 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs` 与其 `.test.mjs`、`apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`、`scripts/check-agc-update-channel-manifests.mjs`、两份里程碑与本文件、pitfalls。 - 验证:`node --test apps/ai-game-creator-shell/scripts/*.test.mjs` → **110 passed / 0 failed**(其中定向批次 `macos-release-identity / prepare-macos-codex / verify-updater-signature / build-release / cargo-features` 60 passed;新增的 mac 身份回归用例直接喂线上那份 0.1.139 release 身份 plist,必须抛错;新增的产物版本守卫用例喂 `_0.1.153_` 残留安装包,必须抛错);`npm run check:production-ops`、`check:encoding`、`check:doc-index`、prettier、eslint、`git diff --check` 通过;只读核对对线上 `dev-win` 全 PASS(含 158 MiB 产物下载验签与旧协议 sha256 一致),对线上 `dev-mac` 精确报出上面两条 FAIL。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6585bb741..da245ef89 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6113,8 +6113,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因**:`codex_app_server/mod.rs` 在 `thread_created` 时无条件调用 `thread/inject_items`,载荷由 `build_direct_project_history_injection_params()` 从 `.agent/conversations/project.jsonl` 构造;**全新项目该文件不存在 → `items: []`**,而 codex app-server 0.155.1 起把空数组当协议错误拒绝。GUI 路径之所以没暴露:前端会先调 `append_direct_project_conversation_message` 把用户消息写进历史,注入时至少有 1 条;`--direct-codex-chat` 这类无前端宿主(以及任何直接调用 `run_direct_game_creator_turn_at` 的夹具/CLI)没有这一步。 - **处理(2026-09-28 已修)**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过 `thread/inject_items`;新增两条单测(空历史不得构造载荷、有历史仍构造 1 条)。 - **复验**:同一条夹具命令下,AGC 进程从「4 秒失败、`requests=[]`」变成「走到 loopback Provider、`requests=3`、`markers.ready=true`、exit=0」——第一层缺陷确实修掉了。 -- **仍未解决(第二层,另一个问题)**:同一个夹具的全部 6 个用例(`completed/passes/mcp/mcp-write/native/deadline`)在修好第一层后都走到 loopback Provider,但**统一**失败在账本阶段:`phase=interrupted`(期望 `completed`/`exhausted`),stderr 为 `agent.runner.failed: Codex app-server 连接终止 … exitStatus=unknown;stderrBytes=1242`,CLI 回执文本为「执行通道已断开,不能自动重放未确认操作」。 +- **第二层(已修)**:修好第一层后,夹具的全部 6 个用例都走到 loopback Provider,但**统一**失败在账本阶段:`phase=interrupted`(期望 `completed`/`exhausted`),CLI 回执文本是「执行通道已断开,不能自动重放未确认操作」。 - **已排除的四层**(都靠插桩,不是推测):① 不是 codex 版本漂移(换成 0.147.0 结果相同);② 不是 stdin 被忽略(`stdio[0]` 改 `pipe` 结果相同);③ **不是 `Drop for ExecutionBinding`**(在该分支插桩打印 phase/`background_done`/`closed`,一次都没打印);④ **app-server 也不是崩溃**(分段打印 stderr 原文,1242 字节全是 `ProgramData known folder 0x80070003`、`Unknown model gpt-5.1-codex`、`powershell shell snapshot` 之类 WARN,没有 panic/error)。 -- **定位到的真正 Killer**:给 `shutdown_game_creator_codex_app_server_inner` 加一行关闭原因日志后,夹具里依次出现 `agent.direct_codex.shutdown reason=宿主执行预算或交付收尾` → `agent.runner.failed: … 连接终止` → (CLI 自己的两次 `shutdown`)。也就是**宿主收尾主动关掉 app-server**:`codex_app_server/execution.rs:938` 的 `shutdown_and_report()` 先 `self.closed.store(true)`、再 `shutdown_game_creator_codex_app_server_inner(inner, "宿主执行预算或交付收尾")`,而后者会给所有在途回合通道发 `CodexTurnEvent::TransportClosed(reason)`(`mod.rs:5207`)。于是**等待中的那一轮**(CLI 单回合宿主就是 CLI 自己的 await)拿到的是「连接断开」,回执变成失败文案、账本落 `interrupted`,尽管这是宿主自己收的尾。修复方向:宿主主动收尾时**先交付回合结果、不要向它发 `TransportClosed`**(或让等待方把「宿主收尾」识别为终态而不是 transport 失败);注意 GUI 常驻 app-server,不会每轮走这条路径,所以影响面是 CLI / 单回合宿主(`--direct-codex-chat` 与依赖它的夹具),不是用户日常对话。 -- **保留的诊断开关**:关闭原因日志留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下(与既有的 `agent.direct_codex.stderr bytes=` 同一开关),以后排查这类「谁关掉的」问题不用再插桩;其余临时插桩都已回滚。 +- **Killer 定位 + 修复(2026-09-28)**:给 `shutdown_game_creator_codex_app_server_inner` 加关闭原因日志后,夹具里依次出现 `agent.direct_codex.shutdown reason=宿主执行预算或交付收尾` → `agent.runner.failed: … 连接终止`。机制是:`shutdown_and_report()`(`execution.rs:938`)先 `self.closed.store(true)` 再关 app-server,而关进程会给在途回合通道发 `CodexTurnEvent::TransportClosed(reason)`;消费这段事件的 `mod.rs:4015` 只看 `adapter.is_host_ending()`(**看的是 session 阶段,不是 `closed`**),阶段还在 Working 时就调了 `fail_turn()`;而 `fail_turn()` 内部**不管 `closed` 与否都会 `session.interrupt(reason)`**,于是正常的宿主收尾被改写成 `Interrupted`、回执文案变成「执行通道已断开」。 + 修复:①`fail_turn()` 在 `is_closed()`(宿主自己收尾)时只把原因追加进终态说明、**不改阶段**,新增 `ExecutionSession::append_terminal_note()`(`interrupt()` 同款写法但不设 `Interrupted`);用户主动终止(`host_stop_requested`)与真实通道故障仍保持原口径。 + 复验(真 AGC CLI → 真 app-server → loopback Responses):`node apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs --agc-exe apps/ai-game-creator-shell/src-tauri/target/debug/genarrative-ai-game-creator-shell.exe --cases completed,passes,mcp,mcp-write,native,deadline` → **6/6 passed**;`completed` 用例的账本从 `phase=interrupted` 变成 `phase=completed`、`executorStopped=true`,`terminalReport` 变成宿主真实交付报告(「本轮已完成宿主验收…已通过:产物 marker.txt」),不再是「执行通道已断开」。定向单测:`cargo test … codex_app_server::execution` 13 passed、`cargo test … direct_execution` 26 passed。 +- **保留的诊断开关**:关闭原因日志留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下(与既有的 `agent.direct_codex.stderr bytes=` 同一开关),以后排查「谁关掉的」不用再插桩。 - **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是 `src-tauri/build.rs` 的 `stage_codex_target()` 只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。现在加了 `prune_stale_codex_components()`:拷贝前删掉不在本轮布局、也不在 `manifest.json`/`NOTICE.md` 白名单里的文件并收掉空目录;实测重建后根目录 `codex.exe` 被清掉、六个声明组件与清单/声明保留。 From c3888037294fbb4e85d8193a655abc58aa28900c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:32:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E7=BB=99=E7=9C=9F=E6=9C=BA=E5=A4=B9?= =?UTF-8?q?=E5=85=B7=E5=8A=A0=20restart=20=E7=94=A8=E4=BE=8B=EF=BC=9A?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E8=BF=9B=E8=A1=8C=E4=B8=AD=E6=9D=80=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=90=8E=E9=83=A8=E5=88=86=E5=8E=86=E5=8F=B2=E4=B8=8E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct-execution-production-fixture:新增 restart 场景(契约 → 部分助手文本 + 阻塞原生工具 → 工具真开始后 SIGTERM 杀进程),断言部分文本与工具卡片按顺序落盘、被杀工具卡片是最后一行且无回执行、账本不伪造成 completed/executorStopped - 复验:completed,passes,mcp,mcp-write,native,deadline,restart 共 7/7 passed - 变异验证:把"等 400ms 再杀"改成"等 20s"(让回合自然跑完)后同一条用例立即变红(AGC must be killed mid-turn) - 记录事实:CLI/无前端宿主不写用户消息(GUI 由前端写),夹具断言据此只校验部分文本与工具卡片顺序 - 文档:DirectProject 里程碑补该轮核对与剩余 UI 侧边界;pitfalls 记 CLI 不写用户消息这一坑 --- .../direct-execution-production-fixture.mjs | 171 ++++++++++++++---- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 8 +- docs/project-memory/shared-memory/pitfalls.md | 1 + 3 files changed, 145 insertions(+), 35 deletions(-) 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 index 1777dd8c9..2b5af0d34 100644 --- a/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs +++ b/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs @@ -150,6 +150,63 @@ async function readLedger(host) { return null; } +/** + * `restart` 用例判据:回合进行中杀掉进程后 + * - 已落盘的用户消息、部分助手文本与工具卡片必须按原顺序保留; + * - 宿主不得为这一轮伪造终态(不能出现最终助手消息,账本不能是 completed / executorStopped)。 + * 对应的里程碑条款是「回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现, + * 且界面不显示忙碌态」里**宿主侧可证的那一半**(界面渲染仍要真实客户端窗口)。 + */ +async function assertRestartKeepsPartialHistory(project, ledger, exit) { + assert( + exit.code !== 0 || exit.signal, + 'restart: AGC must be killed mid-turn, got ' + JSON.stringify(exit), + ); + const history = await fs + .readFile(path.join(project, '.agent/conversations/project.jsonl'), 'utf8') + .catch(() => ''); + const rows = history.split('\n').filter(Boolean); + const raw = rows.join('\n'); + // 注意:CLI / 无前端宿主不写用户消息(GUI 是前端先调 append_direct_project_conversation_message), + // 所以这里只断言"部分文本 + 工具卡片按原顺序落盘",用户消息的持久化属于 GUI 路径。 + const order = ['RESTART-PARTIAL-TEXT', 'exec_command'].map((needle) => + raw.indexOf(needle), + ); + assert( + order.every((index) => index >= 0) && order[0] < order[1], + 'restart: partial text and tool card must stay in order; got ' + + JSON.stringify({ order, rows: rows.length }), + ); + assert( + !raw.includes('Fixture actions finished'), + 'restart: killed turn must not fabricate the final assistant message', + ); + // 被杀的工具卡片必须是最后一条:既证明卡片已落盘,也证明它没有产生任何回执 + // (有回执就说明这一轮跑到了工具结束之后,不再是"进行中终止")。 + const lastRow = rows[rows.length - 1] ?? ''; + assert( + lastRow.includes('restart-slow') && lastRow.includes('exec_command'), + 'restart: the killed tool card must be the last persisted row; got ' + + lastRow.slice(0, 200), + ); + assert( + !rows.some( + (row) => row.includes('restart-slow') && row.includes('"output"'), + ), + 'restart: the killed tool must not have produced an output row', + ); + assert.notEqual( + ledger.phase, + 'completed', + 'restart: killed turn must not claim completion', + ); + assert.notEqual( + ledger.executorStopped, + true, + 'restart: killed turn must not report a fully stopped executor', + ); +} + function ownFixtureTree(directory) { if (process.platform !== 'win32') return; assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); @@ -260,6 +317,7 @@ async function runScenario(name) { 'patch', 'deadline', 'native-session', + 'restart', ].includes(name), 'unknown fixture case', ); @@ -469,18 +527,42 @@ async function runScenario(name) { ), ], ] - : [ - [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'), - ), - ], - ]; + : name === 'restart' + ? [ + [register('started.txt')], + [ + { + type: 'message', + id: 'fixture-restart-partial', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'RESTART-PARTIAL-TEXT', + }, + ], + }, + native( + 'restart-slow', + writes('started.txt', 'started') + + 'setTimeout(()=>{' + + writes('late.txt', 'must not run after kill') + + '},15000);', + ), + ], + ] + : [ + [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 = []; @@ -696,12 +778,29 @@ async function runScenario(name) { child.stderr.on('data', (data) => { stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024); }); + // restart:等工具真的开始跑(marker 落盘)再杀进程,保证"回合进行中终止"是真事, + // 而不是在模型请求还没回来时就把它掐掉。 + const restartKillWatch = + name === 'restart' + ? (async () => { + const marker = path.join(project, 'started.txt'); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline && !(await exists(marker))) { + await sleep(20); + } + assert(await exists(marker), 'restart: tool never started'); + // 留一点时间让这一轮的工具卡片落进项目历史,再终止进程。 + await sleep(400); + child.kill(); + })() + : null; 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); + if (restartKillWatch) await restartKillWatch; await planObservationTask; server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); @@ -829,28 +928,32 @@ async function runScenario(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 === 'restart') { + await assertRestartKeepsPartialHistory(project, ledger.value, exit); + } else { + 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( diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index 13a6e9968..59749f813 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -84,4 +84,10 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - **第二层已修:CLI / 单回合宿主的收尾不再被记成本轮未完成**。定位过程(都靠插桩,不是推测):① 不是 codex 版本漂移(换 0.147.0 相同);② 不是 stdin 被忽略(改 `pipe` 相同);③ 不是 `Drop for ExecutionBinding`(插桩后一次没打印);④ app-server 不是崩溃(1242 字节 stderr 全是 ProgramData/模型元数据/PowerShell snapshot 之类 WARN);⑤ **是宿主自己关的**——关闭原因日志显示 `reason=宿主执行预算或交付收尾`(`execution.rs:938` 的 `shutdown_and_report()` 先置 `closed` 再关 app-server,关进程会给在途回合发 `TransportClosed`)。消费端(`mod.rs:4015`)只看 session 阶段、没看 `closed`,而 `fail_turn()` 内部无论如何都会 `session.interrupt(reason)`,于是正常收尾被改写成 `interrupted`、回执换成「执行通道已断开」。 修复:`fail_turn()` 在 `is_closed()` 时只把原因追加进终态说明(新增 `ExecutionSession::append_terminal_note()`),**不改阶段**;用户主动终止与真实通道故障保持原口径。 **复验(本里程碑现在有了可复跑的真机证路)**:`node apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs --agc-exe apps/ai-game-creator-shell/src-tauri/target/debug/genarrative-ai-game-creator-shell.exe --cases completed,passes,mcp,mcp-write,native,deadline` → **6/6 passed**(真 AGC CLI → 真 app-server → loopback Responses,无账号无付费);`completed` 的账本 `phase=completed`、`executorStopped=true`,`terminalReport` 变成宿主真实交付报告。定向单测 `codex_app_server::execution` 13 passed、`direct_execution` 26 passed。 -- **两条运行时验收仍继续未勾选**:它们的判据里有「界面不显示忙碌态」「页面重进(含切走再切回)能恢复运行中回合并允许终止」,本质要真实客户端窗口;上面的夹具只能证「真实 app-server 下的回合收尾与历史持久化」,证不了 UI 投影。 +- **再次核对(2026-09-28,同一夹具加 `restart` 用例)**:给 `direct-execution-production-fixture.mjs` 新增 `restart` 场景——受控契约注册 → 一轮里同时下发**部分助手文本**与一个**阻塞的原生工具** → 等工具真的开始(`started.txt` 落盘)后 `SIGTERM` 杀掉 AGC 进程。判据(全部实跑通过,`--cases completed,passes,mcp,mcp-write,native,deadline,restart` → **7/7 passed**): + - 进程确实死在回合进行中:`exit={code:null, signal:'SIGTERM'}`; + - `.agent/conversations/project.jsonl` 里 `RESTART-PARTIAL-TEXT` 与工具卡片(`exec_command`)**按原顺序**保留,且被杀的工具卡片是**最后一行**、没有对应回执行(说明它没跑到工具结束之后); + - 宿主没有伪造终态:账本 `phase=working`(不是 `completed`)、`executorStopped=false`; + - **变异验证**:把"等 400ms 再杀"改成"等 20s 再杀"(让这一轮自然跑完)后,同一条用例立刻以 `restart: AGC must be killed mid-turn, got {"code":0,"signal":null}` 变红,证明这些断言确实在区分「进行中终止」和「已完成」。 + - 顺带记录一个事实:CLI / 无前端宿主**不写用户消息**(GUI 是前端先调 `append_direct_project_conversation_message`),所以用例只断言部分文本与工具卡片的顺序;用户消息的持久化属于 GUI 路径。 +- **两条运行时验收仍继续未勾选**:上面这条覆盖了「已落盘的部分文本与工具卡片按原顺序出现、且宿主不假装完成」的宿主侧证据;但判据里的「**界面不显示忙碌态**」「页面重进(含切走再切回)能恢复运行中回合并允许终止」仍要真实客户端窗口,夹具证不了 UI 投影。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index da245ef89..167e07a21 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6119,4 +6119,5 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` 修复:①`fail_turn()` 在 `is_closed()`(宿主自己收尾)时只把原因追加进终态说明、**不改阶段**,新增 `ExecutionSession::append_terminal_note()`(`interrupt()` 同款写法但不设 `Interrupted`);用户主动终止(`host_stop_requested`)与真实通道故障仍保持原口径。 复验(真 AGC CLI → 真 app-server → loopback Responses):`node apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs --agc-exe apps/ai-game-creator-shell/src-tauri/target/debug/genarrative-ai-game-creator-shell.exe --cases completed,passes,mcp,mcp-write,native,deadline` → **6/6 passed**;`completed` 用例的账本从 `phase=interrupted` 变成 `phase=completed`、`executorStopped=true`,`terminalReport` 变成宿主真实交付报告(「本轮已完成宿主验收…已通过:产物 marker.txt」),不再是「执行通道已断开」。定向单测:`cargo test … codex_app_server::execution` 13 passed、`cargo test … direct_execution` 26 passed。 - **保留的诊断开关**:关闭原因日志留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下(与既有的 `agent.direct_codex.stderr bytes=` 同一开关),以后排查「谁关掉的」不用再插桩。 +- **顺带发现(2026-09-28)**:同一个夹具新增的 `restart` 用例证明——CLI / 无前端宿主**不会把用户消息写进 `.agent/conversations/project.jsonl`**(GUI 是前端先调 `append_direct_project_conversation_message`,所以总有至少一条用户条目)。写依赖"历史里一定有用户消息"的夹具断言前先确认是走 GUI 还是 CLI 路径,否则会得到假失败。 - **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是 `src-tauri/build.rs` 的 `stage_codex_target()` 只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。现在加了 `prune_stale_codex_components()`:拷贝前删掉不在本轮布局、也不在 `manifest.json`/`NOTICE.md` 白名单里的文件并收掉空目录;实测重建后根目录 `codex.exe` 被清掉、六个声明组件与清单/声明保留。