From ea33e8c1667cf036405d563ae7521e75b4285c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 10 Aug 2026 12:00:10 +0800 Subject: [PATCH 1/7] =?UTF-8?q?DirectProject=20=E5=AE=A1=E6=89=B9=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=8E=9F=E5=9B=A0=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时 `reason=no-bound-host-execution-adapter`,回包写入失败另记 `agent.direct_codex.approval.response_write_failed`。 --- .../src/agent/codex_app_server/execution.rs | 129 +++++++++++++++--- .../shared-memory/decision-log.md | 8 ++ 2 files changed, 118 insertions(+), 19 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 58eac5eeb..cfbd8d9a9 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 @@ -15,6 +15,10 @@ use tokio::sync::{watch, Notify}; const MAX_PROTOCOL_ITEMS: usize = 2048; const MAX_REQUEST_CACHE: usize = 512; +/// 拒绝原因留痕的条数上限(按 `(method, reason)` 去重后的上限)。 +/// +/// 原因基本是固定的静态分类,上限只是防止宿主错误文本意外发散时无界增长。 +const MAX_DENIED_REASON_LOGS: usize = 64; /// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex /// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。 @@ -38,6 +42,11 @@ pub(super) fn validate_approval_version(version: &str) -> Result<(), String> { } pub(super) fn denied_response(id: u64, method: &str) -> Value { + // 没绑定宿主执行器时一律拒绝。这是「回合没接单 / 适配器已释放」的唯一表现, + // 不留痕线上就只剩一个没有原因的 decline。 + app_log!( + "agent.direct_codex.approval.denied reason=no-bound-host-execution-adapter request_id={id} method={method}" + ); denied(id, method) } @@ -195,6 +204,8 @@ pub(super) struct ExecutionAdapter { turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, + /// 已留痕的拒绝原因(`method\u{1}reason`)。同一回合内同因只记一次,避免模型重试刷屏。 + denied_reasons_logged: Mutex>, } fn identity(value: Option<&Value>) -> Option<&str> { @@ -313,9 +324,40 @@ impl ExecutionAdapter { outcome, turn_failure: Mutex::new(None), host_stop_requested: AtomicBool::new(false), + denied_reasons_logged: Mutex::new(HashSet::new()), }) } + /// 审批 / 交互被拒的原因留痕。 + /// + /// 宿主拒绝 app-server 的请求时,线上只回一个 `decline`,用户和 Codex 都看不到是哪一道闸门 + /// 拦下的;原因只在这里落 AppData 日志。`reason` 只接受本模块的静态分类或宿主自己的错误 + /// 文本,绝不带请求参数、上游正文、路径或凭据。 + /// + /// 同一回合内 `(method, reason)` 只记一次:模型被拒后常反复重试同一动作,逐次记录会把真正 + /// 有用的诊断刷掉;"这一轮被拒过哪些原因"仍然完整。 + fn log_denied_reason(&self, method: &str, reason: &str) { + let key = format!("{method}\u{1}{reason}"); + // 拒绝原因留痕不得反过来影响放行判定:锁不可用就放弃记录。 + let Ok(mut logged) = self.denied_reasons_logged.lock() else { + return; + }; + if logged.len() >= MAX_DENIED_REASON_LOGS || !logged.insert(key) { + return; + } + let distinct = logged.len(); + drop(logged); + app_log!( + "agent.direct_codex.approval.denied thread_id={} method={method} reason={reason} distinct_reasons={distinct}", + self.thread_id + ); + } + + fn denied(&self, id: u64, method: &str, reason: &str) -> Value { + self.log_denied_reason(method, reason); + denied(id, method) + } + pub(super) fn bind_turn(&self, turn_id: &str) -> bool { if turn_id.is_empty() || turn_id.len() > 256 { return false; @@ -454,38 +496,38 @@ impl ExecutionAdapter { pub(super) async fn respond(self: &Arc, id: u64, method: &str, params: &Value) -> Value { if self.closed.load(Ordering::Acquire) || self.is_host_ending() { - return denied(id, method); + return self.denied(id, method, "session-closed-or-host-ending"); } let fingerprint = cohort_key(method, params); let target = { let Ok(mut state) = self.state.lock() else { - return denied(id, method); + return self.denied(id, method, "state-lock-poisoned"); }; if !self.matches_scope(&state, params) { - return denied(id, method); + return self.denied(id, method, "request-out-of-turn-scope"); } if let Some(ticket) = state.tickets.get(&id) { return if ticket.fingerprint == fingerprint { ticket .response .clone() - .unwrap_or_else(|| denied(id, method)) + .unwrap_or_else(|| self.denied(id, method, "ticket-without-response")) } else { - denied(id, method) + self.denied(id, method, "request-id-fingerprint-mismatch") }; } // Approval tasks run concurrently; numeric request IDs need not enter // this mutex in order. Retain seen IDs rather than rejecting by high-water. if state.seen_request_ids.len() >= 8192 || !state.seen_request_ids.insert(id) { - return denied(id, method); + return self.denied(id, method, "request-id-replayed-or-capacity"); } let target = match method { "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { let Some(item_id) = identity(params.get("itemId")) else { - return denied(id, method); + return self.denied(id, method, "approval-without-item-id"); }; let Some(item) = state.items.get(item_id) else { - return denied(id, method); + return self.denied(id, method, "approval-for-unknown-item"); }; let expected = if method == "item/fileChange/requestApproval" { ItemKind::Patch @@ -493,10 +535,10 @@ impl ExecutionAdapter { ItemKind::Command }; if item.kind != expected || item.terminal.is_some() || item.lease.is_some() { - return denied(id, method); + return self.denied(id, method, "item-not-pending-for-approval"); } if state.tickets.values().any(|ticket| matches!(&ticket.target, Target::Item(existing, _) if existing == item_id)) { - return denied(id, method); + return self.denied(id, method, "item-approval-already-pending"); } Target::Item( item_id.to_string(), @@ -514,20 +556,20 @@ impl ExecutionAdapter { != Some("mcp_tool_call") || params.get("mode").and_then(Value::as_str) != Some("form") { - return denied(id, method); + return self.denied(id, method, "unsupported-mcp-elicitation-shape"); } let Some(server) = params.get("serverName").and_then(Value::as_str) else { - return denied(id, method); + return self.denied(id, method, "mcp-elicitation-without-server-name"); }; if server == "agc_tools" || !self.third_party_servers.contains(server) { - return denied(id, method); + return self.denied(id, method, "mcp-server-not-eligible-for-elicitation"); } let Some(arguments) = params.pointer("/_meta/tool_params") else { - return denied(id, method); + return self.denied(id, method, "mcp-elicitation-without-tool-params"); }; let key = cohort_key(server, arguments); let Some(cohort) = state.cohorts.get(&key) else { - return denied(id, method); + return self.denied(id, method, "mcp-cohort-not-registered"); }; let pending_members = cohort .members @@ -541,12 +583,12 @@ impl ExecutionAdapter { .count(); let seen_tickets = state.tickets.values().filter(|ticket| matches!(&ticket.target, Target::Cohort(existing) if existing == &key)).count(); if pending_members == 0 || cohort.members.len() <= seen_tickets { - return denied(id, method); + return self.denied(id, method, "mcp-cohort-already-covered"); } state.cohorts.get_mut(&key).unwrap().admissions += 1; Target::Cohort(key) } - _ => return denied(id, method), + _ => return self.denied(id, method, "unsupported-approval-method"), }; if state.tickets.len() >= MAX_REQUEST_CACHE { state.tickets.retain(|_, ticket| ticket.response.is_none()); @@ -592,13 +634,17 @@ impl ExecutionAdapter { }) .await .unwrap_or_else(|_| Err("执行许可任务中断".into())); + // 闸门拒绝(合同缺失 / 预算耗尽 / 阶段已收束 / 同一输入重复受理…)的真实原因只在 + // `admitted` 里;它随后会被 move 掉,先取副本,供最后统一留痕。 + let admit_failure = admitted.as_ref().err().cloned(); let mut allowed = false; let mut rejected_lease = None; + let mut denied_reason = None; let entries = { let Ok(mut state) = self.state.lock() else { self.settling.fetch_sub(1, Ordering::AcqRel); self.changed.notify_waiters(); - return denied(id, method); + return self.denied(id, method, "state-lock-poisoned-after-admit"); }; if let Target::Cohort(key) = &target { if let Some(cohort) = state.cohorts.get_mut(key) { @@ -617,6 +663,7 @@ impl ExecutionAdapter { item.lease = Some(entry); allowed = true; } else { + denied_reason = Some("item-left-pending-before-lease-attach"); rejected_lease = Some(entry); } } @@ -625,11 +672,13 @@ impl ExecutionAdapter { cohort.leases.push(entry); allowed = true; } else { + denied_reason = Some("cohort-gone-before-lease-attach"); rejected_lease = Some(entry); } } } } else { + denied_reason = Some("session-closed-before-lease-attach"); rejected_lease = Some(entry); } } @@ -652,7 +701,13 @@ impl ExecutionAdapter { if allowed { accepted(id, method) } else { - denied(id, method) + // 每个请求只留一条:admit 失败优先用宿主的真实错误文本,其次是租约附着阶段的分类。 + let reason = match (admit_failure, denied_reason) { + (Some(error), _) => format!("lease-admit-failed: {error}"), + (None, Some(reason)) => reason.to_string(), + (None, None) => "lease-not-granted".to_string(), + }; + self.denied(id, method, &reason) } } @@ -695,6 +750,11 @@ impl ExecutionAdapter { } pub(super) fn response_write_failed(self: &Arc) { + // 回包写不出去时 Codex 侧等不到 decision,表现为「审批没有回应」;与主动 decline 分开留痕。 + app_log!( + "agent.direct_codex.approval.response_write_failed thread_id={}", + self.thread_id + ); let adapter = Arc::clone(self); tokio::spawn(async move { adapter @@ -1689,4 +1749,35 @@ mod tests { ); assert!(denied(5, "item/tool/call").get("error").is_some()); } + + #[tokio::test] + async fn denied_approval_records_its_reason_once_per_turn() { + let (_temp, adapter) = fixture(); + let method = "item/commandExecution/requestApproval"; + assert_eq!( + adapter.respond(1, method, &approval("missing")).await["result"]["decision"], + "decline" + ); + // 线上只回 decline;原因必须留在适配器上,供 AppData 日志回查是哪一道闸门拦下的。 + let key = format!("{method}\u{1}approval-for-unknown-item"); + assert!(adapter.denied_reasons_logged.lock().unwrap().contains(&key)); + // 模型重试同一动作会换新的 request id,但同一回合同因只留一条。 + assert_eq!( + adapter.respond(2, method, &approval("missing")).await["result"]["decision"], + "decline" + ); + assert_eq!(adapter.denied_reasons_logged.lock().unwrap().len(), 1); + } + + #[test] + fn denied_reason_logging_is_bounded() { + let (_temp, adapter) = fixture(); + for index in 0..(MAX_DENIED_REASON_LOGS + 8) { + adapter.log_denied_reason("unsupported-approval-method", &format!("reason-{index}")); + } + assert_eq!( + adapter.denied_reasons_logged.lock().unwrap().len(), + MAX_DENIED_REASON_LOGS + ); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a7b1adf7e..ab53e88d6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-10-01 DirectProject 审批拒绝原因留痕 + +- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时 `reason=no-bound-host-execution-adapter`,回包写入失败另记 `agent.direct_codex.approval.response_write_failed`。 +- 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 +- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;同一回合内 `(method, reason)` 只记一次,最多 `MAX_DENIED_REASON_LOGS = 64` 条。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell agent::codex_app_server::execution::tests`(15 passed,含 `denied_approval_records_its_reason_once_per_turn`、`denied_reason_logging_is_bounded`)、`npm run check:encoding`、`git diff --check`。 + ## 2026-09-30 release 渠道移除产品名与包名后缀 - 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。 From c34e45e0c49d00a385e439197feead3f77828579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:38:05 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=9C=AA=E7=BB=91=E5=AE=9A=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=99=A8=E6=97=B6=E7=9A=84=E4=BA=A4=E4=BA=92=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E6=8C=89=E7=9C=9F=E5=AE=9E=E7=BB=93=E6=9E=9C=E7=95=99?= =?UTF-8?q?=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未绑定宿主执行器的交互兜底不再一律记成审批拒绝 - 新增稳定键 agent.direct_codex.interaction.no_adapter,字段为 method / outcome / request_id - outcome 区分 decline / empty-permissions / unsupported-method,权限申请与未知方法不再被误标为 decline - 同步更新 shared-memory 决策记录里的日志键口径 --- .../src/agent/codex_app_server/execution.rs | 16 +++++++++++++--- .../project-memory/shared-memory/decision-log.md | 2 +- 2 files changed, 14 insertions(+), 4 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 cfbd8d9a9..1f37e656c 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 @@ -41,11 +41,21 @@ pub(super) fn validate_approval_version(version: &str) -> Result<(), String> { } } +/// 未绑定宿主执行器时的交互兜底:一律拒绝,并把**真实结果形状**留痕。 +/// +/// 这是「回合没接单 / 适配器已释放」的唯一表现,不留痕线上就只剩一个没有原因的拒绝。 +/// 不同交互方法的拒绝形状并不相同(审批是 `decline`,权限申请是空授权,未知方法直接报错), +/// 所以日志按 `outcome` 分类,不能一律记成审批拒绝。 pub(super) fn denied_response(id: u64, method: &str) -> Value { - // 没绑定宿主执行器时一律拒绝。这是「回合没接单 / 适配器已释放」的唯一表现, - // 不留痕线上就只剩一个没有原因的 decline。 + let outcome = match method { + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "mcpServer/elicitation/request" => "decline", + "item/permissions/requestApproval" => "empty-permissions", + _ => "unsupported-method", + }; app_log!( - "agent.direct_codex.approval.denied reason=no-bound-host-execution-adapter request_id={id} method={method}" + "agent.direct_codex.interaction.no_adapter method={method} outcome={outcome} request_id={id}" ); denied(id, method) } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ab53e88d6..f718c57e3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,7 +2,7 @@ ## 2026-10-01 DirectProject 审批拒绝原因留痕 -- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时 `reason=no-bound-host-execution-adapter`,回包写入失败另记 `agent.direct_codex.approval.response_write_failed`。 +- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`),回包未送达另记 `agent.direct_codex.approval.response_write_failed`。 - 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 - 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;同一回合内 `(method, reason)` 只记一次,最多 `MAX_DENIED_REASON_LOGS = 64` 条。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 From e3041d84e5a6a378b915fb124d74007a4dd08168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:41:26 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=9C=AA=E7=BB=91=E5=AE=9A=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=99=A8=E4=B8=8E=E5=9B=9E=E5=8C=85=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=9A=84=E7=95=99=E7=97=95=E5=8A=A0=E6=9C=89=E7=95=8C=E5=8E=BB?= =?UTF-8?q?=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未绑定宿主执行器的交互兜底按进程级 (method, outcome) 只写第一行,新增 distinct_outcomes - 适配器拒绝留痕抽出共用的 log_once,回包未送达同样按回合内原因只记一次 - 上限沿用 MAX_DENIED_REASON_LOGS = 64,模型重试不再逐次刷日志 - 同步更新 shared-memory 决策记录里的去重口径 --- .../src/agent/codex_app_server/execution.rs | 60 +++++++++++++++---- .../shared-memory/decision-log.md | 2 +- 2 files changed, 49 insertions(+), 13 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 1f37e656c..29806b1c9 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 @@ -9,7 +9,7 @@ use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use std::time::Duration; use tokio::sync::{watch, Notify}; @@ -19,6 +19,11 @@ const MAX_REQUEST_CACHE: usize = 512; /// /// 原因基本是固定的静态分类,上限只是防止宿主错误文本意外发散时无界增长。 const MAX_DENIED_REASON_LOGS: usize = 64; +/// 未绑定执行器路径的进程级去重集合。 +/// +/// 这条路径没有适配器实例可挂去重状态(那正是它存在的前提),只能用进程级集合;上限与 +/// [`MAX_DENIED_REASON_LOGS`] 同口径。 +static NO_ADAPTER_INTERACTION_LOGGED: OnceLock>> = OnceLock::new(); /// 逐次审批协议的版本门禁:发行构建只接受捆绑侧车的固定版本;开发构建用宿主自带的 Codex /// (Linux 与未 stage 侧车时没有固定版本可用),按 profile 直接跳过该门禁。 @@ -54,12 +59,29 @@ pub(super) fn denied_response(id: u64, method: &str) -> Value { "item/permissions/requestApproval" => "empty-permissions", _ => "unsupported-method", }; - app_log!( - "agent.direct_codex.interaction.no_adapter method={method} outcome={outcome} request_id={id}" - ); + log_no_adapter_interaction_once(method, outcome, id); denied(id, method) } +/// 未绑定执行器时的交互兜底留痕:进程级有界去重。 +/// +/// 模型被拒后常会反复重试同一个动作,逐次记录会把其它诊断刷掉;同一 `(method, outcome)` 只写 +/// 第一行,`distinct_outcomes` 保留"一共出现过几种"的信息。 +fn log_no_adapter_interaction_once(method: &str, outcome: &str, request_id: u64) { + let logged = NO_ADAPTER_INTERACTION_LOGGED.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut logged) = logged.lock() else { + return; + }; + if logged.len() >= MAX_DENIED_REASON_LOGS || !logged.insert(format!("{method}\u{1}{outcome}")) { + return; + } + let distinct = logged.len(); + drop(logged); + app_log!( + "agent.direct_codex.interaction.no_adapter method={method} outcome={outcome} request_id={request_id} distinct_outcomes={distinct}" + ); +} + pub(super) struct ExecutionBinding { inner: Weak, pub(super) adapter: Arc, @@ -348,7 +370,18 @@ impl ExecutionAdapter { /// 有用的诊断刷掉;"这一轮被拒过哪些原因"仍然完整。 fn log_denied_reason(&self, method: &str, reason: &str) { let key = format!("{method}\u{1}{reason}"); - // 拒绝原因留痕不得反过来影响放行判定:锁不可用就放弃记录。 + let thread_id = &self.thread_id; + self.log_once(key, |distinct| { + format!( + "agent.direct_codex.approval.denied thread_id={thread_id} method={method} reason={reason} distinct_reasons={distinct}" + ) + }); + } + + /// 有界去重留痕:同一回合内同 `key` 只写第一行。 + /// + /// 共用一把独立于审批状态的锁;锁不可用就放弃记录,留痕不得反过来影响放行判定。 + fn log_once(&self, key: String, build_line: impl FnOnce(usize) -> String) { let Ok(mut logged) = self.denied_reasons_logged.lock() else { return; }; @@ -357,10 +390,7 @@ impl ExecutionAdapter { } let distinct = logged.len(); drop(logged); - app_log!( - "agent.direct_codex.approval.denied thread_id={} method={method} reason={reason} distinct_reasons={distinct}", - self.thread_id - ); + app_log!("{}", build_line(distinct)); } fn denied(&self, id: u64, method: &str, reason: &str) -> Value { @@ -761,9 +791,15 @@ impl ExecutionAdapter { pub(super) fn response_write_failed(self: &Arc) { // 回包写不出去时 Codex 侧等不到 decision,表现为「审批没有回应」;与主动 decline 分开留痕。 - app_log!( - "agent.direct_codex.approval.response_write_failed thread_id={}", - self.thread_id + // 同一回合内同一原因只留一行:这一步可能被重复触发。 + self.log_once( + "host-interaction\u{1}response-write-failed".to_string(), + |_| { + format!( + "agent.direct_codex.approval.response_write_failed thread_id={}", + self.thread_id + ) + }, ); let adapter = Arc::clone(self); tokio::spawn(async move { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f718c57e3..d9c28492e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`),回包未送达另记 `agent.direct_codex.approval.response_write_failed`。 - 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 -- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;同一回合内 `(method, reason)` 只记一次,最多 `MAX_DENIED_REASON_LOGS = 64` 条。 +- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 - 验证:`cargo test --bin genarrative-ai-game-creator-shell agent::codex_app_server::execution::tests`(15 passed,含 `denied_approval_records_its_reason_once_per_turn`、`denied_reason_logging_is_bounded`)、`npm run check:encoding`、`git diff --check`。 From ce4ada8bb35e7d7f72942b974566abcedea751b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:44:25 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E5=9B=9E=E5=8C=85=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=95=99=E7=97=95=E6=94=B9=E4=B8=BA=E8=AE=B0=E5=BD=95=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - response_write_failed 改名为 response_delivery_failed,新增 ResponseDeliveryFailure 枚举 - 两个调用点分别传 Write(stdin 写入 / flush 失败)与 TurnBindMismatch(bind_turn 返回 false) - 日志键改为 agent.direct_codex.approval.response_delivery_failed 并带 cause 字段,回合绑定失败不再被误报成写入失败 - 同步更新 shared-memory 决策记录里的日志键口径 --- .../src/agent/codex_app_server/execution.rs | 31 ++++++++++++++++--- .../src/agent/codex_app_server/mod.rs | 8 +++-- .../shared-memory/decision-log.md | 2 +- 3 files changed, 32 insertions(+), 9 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 29806b1c9..cd6b98452 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 @@ -291,6 +291,26 @@ fn accepted(id: u64, method: &str) -> Value { } } +/// app-server 交互回包未能送达的原因。 +/// +/// 两种失败共用同一条收束路径,但来源不同:写失败是 Codex 根本没收到 decision,回合绑定不一致 +/// 是回包已经产出、宿主无法确认执行作用域。留痕必须写真实原因,不能按函数名反推。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ResponseDeliveryFailure { + /// stdin 写入 / flush 失败。 + Write, + /// `bind_turn` 返回 false:回合绑定不一致。 + TurnBindMismatch, +} +impl ResponseDeliveryFailure { + fn as_str(self) -> &'static str { + match self { + Self::Write => "response-write-failed", + Self::TurnBindMismatch => "turn-bind-mismatch", + } + } +} + impl ExecutionAdapter { pub(super) async fn for_turn( root: &Path, @@ -789,15 +809,16 @@ impl ExecutionAdapter { .await; } - pub(super) fn response_write_failed(self: &Arc) { - // 回包写不出去时 Codex 侧等不到 decision,表现为「审批没有回应」;与主动 decline 分开留痕。 + pub(super) fn response_delivery_failed(self: &Arc, cause: ResponseDeliveryFailure) { + // 回包没送达 Codex 时线上表现为「审批没有回应」;与主动 decline 分开留痕。 // 同一回合内同一原因只留一行:这一步可能被重复触发。 self.log_once( - "host-interaction\u{1}response-write-failed".to_string(), + format!("host-interaction\u{1}response-delivery:{}", cause.as_str()), |_| { format!( - "agent.direct_codex.approval.response_write_failed thread_id={}", - self.thread_id + "agent.direct_codex.approval.response_delivery_failed thread_id={} cause={}", + self.thread_id, + cause.as_str() ) }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3c00d1410..a9b0a3560 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -18,7 +18,7 @@ mod execution; mod model_catalog; pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; -use execution::ExecutionAdapter; +use execution::{ExecutionAdapter, ResponseDeliveryFailure}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -4884,7 +4884,7 @@ async fn read_game_creator_codex_app_server_stdout( }; if !sent { if let Some(adapter) = adapter { - adapter.response_write_failed(); + adapter.response_delivery_failed(ResponseDeliveryFailure::Write); } } }); @@ -4916,7 +4916,9 @@ async fn read_game_creator_codex_app_server_stdout( let adapter = inner.execution.lock().ok().and_then(|slot| slot.clone()); if let Some(adapter) = adapter { if !adapter.bind_turn(turn_id) { - adapter.response_write_failed(); + adapter.response_delivery_failed( + ResponseDeliveryFailure::TurnBindMismatch, + ); } } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d9c28492e..a00fa21cc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,7 +2,7 @@ ## 2026-10-01 DirectProject 审批拒绝原因留痕 -- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`),回包未送达另记 `agent.direct_codex.approval.response_write_failed`。 +- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。 - 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 - 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 From 493457c3eaf11813e54e4d40c5727860a2e2c5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:48:25 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E7=95=99=E7=97=95=E5=89=8D=E7=BB=9F=E4=B8=80=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log_denied_reason 在唯一出口调用 sanitize_diagnostic_message,透传的宿主错误文本不再把绝对路径写进日志 - 去重键改用脱敏后的文本,内存与 stderr 拷贝同样不带原文 - 新增 denied_reason_logging_scrubs_embedded_paths 用例固化该约定 - 同步更新 shared-memory 决策记录里的脱敏口径 --- .../src/agent/codex_app_server/execution.rs | 17 +++++++++++++++++ .../shared-memory/decision-log.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) 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 cd6b98452..10ab21172 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 @@ -389,6 +389,10 @@ impl ExecutionAdapter { /// 同一回合内 `(method, reason)` 只记一次:模型被拒后常反复重试同一动作,逐次记录会把真正 /// 有用的诊断刷掉;"这一轮被拒过哪些原因"仍然完整。 fn log_denied_reason(&self, method: &str, reason: &str) { + // 契约:留痕不带路径、上游正文或凭据。静态分类本来就没有;`lease-admit-failed` 这类透传的 + // 宿主错误文本可能内嵌绝对项目路径(如 `validate_patch_paths` 的路径校验错误),所以统一 + // 在唯一出口脱敏——去重键用脱敏后的文本,日志拷贝与 stderr 拷贝都不会带原文。 + let reason = crate::sanitize_diagnostic_message(reason, None); let key = format!("{method}\u{1}{reason}"); let thread_id = &self.thread_id; self.log_once(key, |distinct| { @@ -1847,4 +1851,17 @@ mod tests { MAX_DENIED_REASON_LOGS ); } + + #[test] + fn denied_reason_logging_scrubs_embedded_paths() { + let (_temp, adapter) = fixture(); + adapter.log_denied_reason( + "item/commandExecution/requestApproval", + "lease-admit-failed: 读取路径失败:/home/someone/secret-project/app.ts", + ); + let logged = adapter.denied_reasons_logged.lock().unwrap(); + let key = logged.iter().next().expect("拒绝原因应留痕"); + assert!(key.contains("lease-admit-failed"), "{key}"); + assert!(!key.contains("/home/someone"), "留痕不得带绝对路径:{key}"); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a00fa21cc..8e1fc3c67 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。 - 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 -- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本,不带请求参数、上游正文、路径或凭据;有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。 +- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本;透传的宿主错误在留痕出口统一走 `sanitize_diagnostic_message`,不带请求参数、上游正文、路径或凭据(去重键也用脱敏后的文本)。有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 - 验证:`cargo test --bin genarrative-ai-game-creator-shell agent::codex_app_server::execution::tests`(15 passed,含 `denied_approval_records_its_reason_once_per_turn`、`denied_reason_logging_is_bounded`)、`npm run check:encoding`、`git diff --check`。 From fbfc0b62b9f0a1996cf52c42142c9b9298c97620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:04:02 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=E4=B8=B4=E7=95=8C=E5=8C=BA=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - respond 的 target 解析抽成 resolve_approval_gate,锁内只做状态判定与 ticket 登记 - 新增 ApprovalGateOutcome(Proceed / Cached / Deny),拒绝原因带出临界区,state 释放后才调用 denied 写日志 - 拒绝留痕不再持有 state 做同步文件 I/O(app_log 写入与日志轮转),不再拉长审批临界区 - 15 条拒绝原因分类逐条等价迁移,行为与返回形状不变 --- .../src/agent/codex_app_server/execution.rs | 234 ++++++++++-------- 1 file changed, 134 insertions(+), 100 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 10ab21172..2c31b17c9 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 @@ -210,6 +210,19 @@ struct Ticket { response: Option, } +/// 锁内解析一次审批 / 交互请求的结果。 +/// +/// 解析只做状态判定与登记;拒绝原因的留痕由调用方在**释放 `state` 之后**完成,避免持有审批 +/// 临界区做同步文件 I/O(见 `ExecutionAdapter::respond`)。 +enum ApprovalGateOutcome { + /// 已登记 ticket,进入租约申请与放行阶段。 + Proceed(Target), + /// 该 request id 已有结论,直接复用,不再申请租约。 + Cached(Value), + /// 拒绝,附留痕用的原因分类。 + Deny(&'static str), +} + #[derive(Default)] struct ProtocolState { turn_id: Option, @@ -558,114 +571,135 @@ impl ExecutionAdapter { self.spawn_settlement(entries); } + /// 锁内解析一次审批 / 交互请求:登记待放行的 ticket,或给出拒绝原因。 + /// + /// **只做状态判定与登记,不做 I/O、不写日志**:调用方必须在释放 `state` 之后再留痕。 + fn resolve_approval_gate( + &self, + state: &mut ProtocolState, + id: u64, + method: &str, + params: &Value, + fingerprint: &str, + ) -> ApprovalGateOutcome { + if !self.matches_scope(state, params) { + return ApprovalGateOutcome::Deny("request-out-of-turn-scope"); + } + if let Some(ticket) = state.tickets.get(&id) { + return if ticket.fingerprint == fingerprint { + match ticket.response.clone() { + Some(response) => ApprovalGateOutcome::Cached(response), + None => ApprovalGateOutcome::Deny("ticket-without-response"), + } + } else { + ApprovalGateOutcome::Deny("request-id-fingerprint-mismatch") + }; + } + // Approval tasks run concurrently; numeric request IDs need not enter + // this mutex in order. Retain seen IDs rather than rejecting by high-water. + if state.seen_request_ids.len() >= 8192 || !state.seen_request_ids.insert(id) { + return ApprovalGateOutcome::Deny("request-id-replayed-or-capacity"); + } + let target = match method { + "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { + let Some(item_id) = identity(params.get("itemId")) else { + return ApprovalGateOutcome::Deny("approval-without-item-id"); + }; + let Some(item) = state.items.get(item_id) else { + return ApprovalGateOutcome::Deny("approval-for-unknown-item"); + }; + let expected = if method == "item/fileChange/requestApproval" { + ItemKind::Patch + } else { + ItemKind::Command + }; + if item.kind != expected || item.terminal.is_some() || item.lease.is_some() { + return ApprovalGateOutcome::Deny("item-not-pending-for-approval"); + } + if state.tickets.values().any(|ticket| matches!(&ticket.target, Target::Item(existing, _) if existing == item_id)) { + return ApprovalGateOutcome::Deny("item-approval-already-pending"); + } + Target::Item( + item_id.to_string(), + if expected == ItemKind::Patch { + EffectKind::Write + } else { + EffectKind::Execute + }, + ) + } + "mcpServer/elicitation/request" => { + if params + .pointer("/_meta/codex_approval_kind") + .and_then(Value::as_str) + != Some("mcp_tool_call") + || params.get("mode").and_then(Value::as_str) != Some("form") + { + return ApprovalGateOutcome::Deny("unsupported-mcp-elicitation-shape"); + } + let Some(server) = params.get("serverName").and_then(Value::as_str) else { + return ApprovalGateOutcome::Deny("mcp-elicitation-without-server-name"); + }; + if server == "agc_tools" || !self.third_party_servers.contains(server) { + return ApprovalGateOutcome::Deny("mcp-server-not-eligible-for-elicitation"); + } + let Some(arguments) = params.pointer("/_meta/tool_params") else { + return ApprovalGateOutcome::Deny("mcp-elicitation-without-tool-params"); + }; + let key = cohort_key(server, arguments); + let Some(cohort) = state.cohorts.get(&key) else { + return ApprovalGateOutcome::Deny("mcp-cohort-not-registered"); + }; + let pending_members = cohort + .members + .iter() + .filter(|member| { + state + .items + .get(*member) + .is_some_and(|entry| entry.terminal.is_none()) + }) + .count(); + let seen_tickets = state.tickets.values().filter(|ticket| matches!(&ticket.target, Target::Cohort(existing) if existing == &key)).count(); + if pending_members == 0 || cohort.members.len() <= seen_tickets { + return ApprovalGateOutcome::Deny("mcp-cohort-already-covered"); + } + state.cohorts.get_mut(&key).unwrap().admissions += 1; + Target::Cohort(key) + } + _ => return ApprovalGateOutcome::Deny("unsupported-approval-method"), + }; + if state.tickets.len() >= MAX_REQUEST_CACHE { + state.tickets.retain(|_, ticket| ticket.response.is_none()); + } + state.tickets.insert( + id, + Ticket { + fingerprint: fingerprint.to_string(), + target: target.clone(), + response: None, + }, + ); + ApprovalGateOutcome::Proceed(target) + } + pub(super) async fn respond(self: &Arc, id: u64, method: &str, params: &Value) -> Value { if self.closed.load(Ordering::Acquire) || self.is_host_ending() { return self.denied(id, method, "session-closed-or-host-ending"); } let fingerprint = cohort_key(method, params); - let target = { + // 解析与登记在锁内一次完成;`app_log!` 是同步文件 I/O(必要时还会轮转日志),所以拒绝 + // 留痕必须等 `state` 释放之后再做,否则会把审批临界区拉长到磁盘延迟。 + let outcome = { let Ok(mut state) = self.state.lock() else { return self.denied(id, method, "state-lock-poisoned"); }; - if !self.matches_scope(&state, params) { - return self.denied(id, method, "request-out-of-turn-scope"); - } - if let Some(ticket) = state.tickets.get(&id) { - return if ticket.fingerprint == fingerprint { - ticket - .response - .clone() - .unwrap_or_else(|| self.denied(id, method, "ticket-without-response")) - } else { - self.denied(id, method, "request-id-fingerprint-mismatch") - }; - } - // Approval tasks run concurrently; numeric request IDs need not enter - // this mutex in order. Retain seen IDs rather than rejecting by high-water. - if state.seen_request_ids.len() >= 8192 || !state.seen_request_ids.insert(id) { - return self.denied(id, method, "request-id-replayed-or-capacity"); - } - let target = match method { - "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { - let Some(item_id) = identity(params.get("itemId")) else { - return self.denied(id, method, "approval-without-item-id"); - }; - let Some(item) = state.items.get(item_id) else { - return self.denied(id, method, "approval-for-unknown-item"); - }; - let expected = if method == "item/fileChange/requestApproval" { - ItemKind::Patch - } else { - ItemKind::Command - }; - if item.kind != expected || item.terminal.is_some() || item.lease.is_some() { - return self.denied(id, method, "item-not-pending-for-approval"); - } - if state.tickets.values().any(|ticket| matches!(&ticket.target, Target::Item(existing, _) if existing == item_id)) { - return self.denied(id, method, "item-approval-already-pending"); - } - Target::Item( - item_id.to_string(), - if expected == ItemKind::Patch { - EffectKind::Write - } else { - EffectKind::Execute - }, - ) - } - "mcpServer/elicitation/request" => { - if params - .pointer("/_meta/codex_approval_kind") - .and_then(Value::as_str) - != Some("mcp_tool_call") - || params.get("mode").and_then(Value::as_str) != Some("form") - { - return self.denied(id, method, "unsupported-mcp-elicitation-shape"); - } - let Some(server) = params.get("serverName").and_then(Value::as_str) else { - return self.denied(id, method, "mcp-elicitation-without-server-name"); - }; - if server == "agc_tools" || !self.third_party_servers.contains(server) { - return self.denied(id, method, "mcp-server-not-eligible-for-elicitation"); - } - let Some(arguments) = params.pointer("/_meta/tool_params") else { - return self.denied(id, method, "mcp-elicitation-without-tool-params"); - }; - let key = cohort_key(server, arguments); - let Some(cohort) = state.cohorts.get(&key) else { - return self.denied(id, method, "mcp-cohort-not-registered"); - }; - let pending_members = cohort - .members - .iter() - .filter(|member| { - state - .items - .get(*member) - .is_some_and(|entry| entry.terminal.is_none()) - }) - .count(); - let seen_tickets = state.tickets.values().filter(|ticket| matches!(&ticket.target, Target::Cohort(existing) if existing == &key)).count(); - if pending_members == 0 || cohort.members.len() <= seen_tickets { - return self.denied(id, method, "mcp-cohort-already-covered"); - } - state.cohorts.get_mut(&key).unwrap().admissions += 1; - Target::Cohort(key) - } - _ => return self.denied(id, method, "unsupported-approval-method"), - }; - if state.tickets.len() >= MAX_REQUEST_CACHE { - state.tickets.retain(|_, ticket| ticket.response.is_none()); - } - state.tickets.insert( - id, - Ticket { - fingerprint, - target: target.clone(), - response: None, - }, - ); - target + self.resolve_approval_gate(&mut state, id, method, params, &fingerprint) + }; + let target = match outcome { + ApprovalGateOutcome::Proceed(target) => target, + ApprovalGateOutcome::Cached(response) => return response, + ApprovalGateOutcome::Deny(reason) => return self.denied(id, method, reason), }; self.settling.fetch_add(1, Ordering::AcqRel); let root = self.root.clone(); From 2220682e583b685fb739b5bfb401a419f7872eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 16:08:00 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=A4=B1=E8=B4=A5=E8=A2=AB=E8=AF=B4=E6=88=90?= =?UTF-8?q?=E5=9B=9E=E5=8C=85=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResponseDeliveryFailure 新增 interrupt_reason,按 cause 给回合终态说明 - 只有 response-write-failed 保留「执行许可回包丢失」;turn-bind-mismatch 改为说明回合绑定不一致、作用域无法确认 - 新增 response_delivery_failure_texts_match_their_cause 用例钉住两句文案 - 同步更新 shared-memory 决策记录里的收束说明口径 --- .../src/agent/codex_app_server/execution.rs | 27 ++++++++++++++++--- .../shared-memory/decision-log.md | 2 +- 2 files changed, 25 insertions(+), 4 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 2c31b17c9..72b02c321 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 @@ -322,6 +322,19 @@ impl ResponseDeliveryFailure { Self::TurnBindMismatch => "turn-bind-mismatch", } } + + /// 写进回合终态说明的收束原因。 + /// + /// 两种失败对用户的意义不同:写失败是回包真的没送出去;绑定不一致是回包已经产出,只是宿主 + /// 无法确认它属于哪一轮。不能共用「回包丢失」这一句,否则报告会把绑定问题说成丢包。 + fn interrupt_reason(self) -> &'static str { + match self { + Self::Write => "执行许可回包丢失,不能确认原操作是否已执行。", + Self::TurnBindMismatch => { + "回合绑定与当前 app-server 不一致,无法确认本轮操作的作用域;已停止本轮执行。" + } + } + } } impl ExecutionAdapter { @@ -862,9 +875,7 @@ impl ExecutionAdapter { ); let adapter = Arc::clone(self); tokio::spawn(async move { - adapter - .interrupt("执行许可回包丢失,不能确认原操作是否已执行。") - .await; + adapter.interrupt(cause.interrupt_reason()).await; }); } @@ -1898,4 +1909,14 @@ mod tests { assert!(key.contains("lease-admit-failed"), "{key}"); assert!(!key.contains("/home/someone"), "留痕不得带绝对路径:{key}"); } + + #[test] + fn response_delivery_failure_texts_match_their_cause() { + assert!(ResponseDeliveryFailure::Write + .interrupt_reason() + .contains("回包丢失")); + let bind = ResponseDeliveryFailure::TurnBindMismatch.interrupt_reason(); + assert!(bind.contains("绑定"), "{bind}"); + assert!(!bind.contains("回包丢失"), "绑定失败不是回包丢失:{bind}"); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8e1fc3c67..6b3318345 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。 - 原因:线上对审批只回 `{"decision":"decline"}`,模型与用户都看不到是哪一道闸门(合同缺失 / 预算耗尽 / 阶段已收束 / item 不在途 / 无适配器)拦下的,只能靠复现。 -- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本;透传的宿主错误在留痕出口统一走 `sanitize_diagnostic_message`,不带请求参数、上游正文、路径或凭据(去重键也用脱敏后的文本)。有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。 +- 边界:`reason` 只接受 `execution.rs` 内的静态分类或宿主自己的错误文本;透传的宿主错误在留痕出口统一走 `sanitize_diagnostic_message`,不带请求参数、上游正文、路径或凭据(去重键也用脱敏后的文本)。有界去重一律按 `MAX_DENIED_REASON_LOGS = 64` 条封顶——适配器路径按回合内 `(method, reason)`、未绑定执行器路径按进程级 `(method, outcome)`、回包未送达按回合内原因各只记一次。拒绝留痕在释放审批临界区(`state`)之后进行;回合收束说明按 `cause` 区分,只有 `response-write-failed` 才说「回包丢失」,`turn-bind-mismatch` 说明作用域无法确认。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs`。 - 验证:`cargo test --bin genarrative-ai-game-creator-shell agent::codex_app_server::execution::tests`(15 passed,含 `denied_approval_records_its_reason_once_per_turn`、`denied_reason_logging_is_bounded`)、`npm run check:encoding`、`git diff --check`。