未绑定执行器与回包失败的留痕加有界去重

- 未绑定宿主执行器的交互兜底按进程级 (method, outcome) 只写第一行,新增 distinct_outcomes
- 适配器拒绝留痕抽出共用的 log_once,回包未送达同样按回合内原因只记一次
- 上限沿用 MAX_DENIED_REASON_LOGS = 64,模型重试不再逐次刷日志
- 同步更新 shared-memory 决策记录里的去重口径
This commit is contained in:
2026-10-01 15:41:26 +08:00
parent c34e45e0c4
commit e3041d84e5
2 changed files with 49 additions and 13 deletions
@@ -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<Mutex<HashSet<String>>> = 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<CodexAppServerInner>,
pub(super) adapter: Arc<ExecutionAdapter>,
@@ -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<Self>) {
// 回包写不出去时 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 {
@@ -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`。