新增 direct-codex 回合终止与推理档写入命令

新增 cancel_direct_codex_turn:按项目登记正在运行的 direct-codex 回合句柄,终止时复用 CodexTurnStartCancellation 只发 turn/interrupt;无活动回合、clientTurnId 不匹配、进程已退出都返回可读原因。

新增 select_game_creator_reasoning_effort:按 select_game_creator_model 的既有配置写入模式,只改 llm.reasoningEffort 后落盘,读接口语义不变。

codex_app_server.rs 追加 DirectCodexActiveTurnTable 注册表与回合登记/注销 guard,并补 2 个单测(选中/注销哪一轮、注册键归一化);既有事件与命令语义不动。
This commit is contained in:
2026-09-15 18:17:46 +08:00
parent a60c04b53d
commit 1a2331bdb1
4 changed files with 219 additions and 1 deletions
@@ -35,7 +35,8 @@ mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
direct_game_creator_home_codex_chat,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -216,6 +216,12 @@ impl CodexTurnStartCancellation {
self.maybe_interrupt();
}
/// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须
/// 明确报错,而不是静默成功让界面以为回合已经停了。
fn app_server_alive(&self) -> bool {
self.inner.strong_count() > 0
}
fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.maybe_interrupt();
@@ -2752,6 +2758,17 @@ impl CodexAppServerConnection {
}
let turn_start_cancellation =
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
let _active_turn_guard = direct_tool_call_turn_id
.as_deref()
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.map(|turn_id| {
register_active_direct_codex_turn(
direct_codex_active_turn_key(history_root),
turn_id,
Arc::clone(&turn_start_cancellation),
)
});
let mut turn_start_guard = CodexTurnStartGuard {
cancellation: Arc::clone(&turn_start_cancellation),
armed: true,
@@ -3059,6 +3076,135 @@ impl Drop for CodexTurnStartGuard {
}
}
/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。
struct DirectCodexActiveTurnTable<T> {
entries: HashMap<std::path::PathBuf, (String, T)>,
}
impl<T> DirectCodexActiveTurnTable<T> {
fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) {
self.entries
.insert(key, (client_turn_id.to_string(), value));
}
/// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。
fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) {
if self
.entries
.get(key)
.is_some_and(|(_, value)| is_same(value))
{
self.entries.remove(key);
}
}
/// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回
/// 可读原因,绝不误伤另一个回合。
fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> {
let active = self
.entries
.get(key)
.ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?;
if let Some(expected) = client_turn_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if active.0 != expected {
return Err("正在运行的是另一个陶泥儿回合,已拒绝终止".to_string());
}
}
Ok(active)
}
}
/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。
///
/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出
/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由
/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。
static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock<
std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>>,
> = OnceLock::new();
fn direct_codex_active_turns(
) -> &'static std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>> {
GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS
.get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new()))
}
/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀),
/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。
fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf {
let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
match canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
{
Some(stripped) => std::path::PathBuf::from(stripped),
None => canonical,
}
}
struct DirectCodexActiveTurnGuard {
key: std::path::PathBuf,
cancellation: Arc<CodexTurnStartCancellation>,
}
impl Drop for DirectCodexActiveTurnGuard {
fn drop(&mut self) {
let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else {
return;
};
let Ok(mut entries) = active_turns.lock() else {
return;
};
let cancellation = Arc::clone(&self.cancellation);
entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation));
}
}
/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。
fn register_active_direct_codex_turn(
key: std::path::PathBuf,
client_turn_id: &str,
cancellation: Arc<CodexTurnStartCancellation>,
) -> DirectCodexActiveTurnGuard {
if let Ok(mut entries) = direct_codex_active_turns().lock() {
entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation));
}
DirectCodexActiveTurnGuard { key, cancellation }
}
/// 终止当前项目正在运行的 Direct 回合。
///
/// 只向正在跑的 Codex app-server 回合发 `turn/interrupt`app-server 随后回
/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回),
/// 不动任何既有事件或命令语义。
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
) -> Result<(), String> {
let key = direct_codex_active_turn_key(root);
let cancellation = {
let entries = direct_codex_active_turns()
.lock()
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
let (_, cancellation) = entries.select(&key, client_turn_id)?;
if !cancellation.app_server_alive() {
return Err("陶泥儿执行进程已退出,无法终止本轮;请重新发送这条消息".to_string());
}
Arc::clone(cancellation)
};
cancellation.cancel();
Ok(())
}
struct CodexThreadLease {
connection: CodexAppServerConnection,
key: CodexNodeThreadKey,
@@ -4071,6 +4217,52 @@ pub(crate) fn build_direct_codex_history_prompt(
mod tests {
use super::*;
/// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要
/// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。
#[test]
fn direct_codex_active_turn_table_selects_only_the_running_turn() {
let mut table: DirectCodexActiveTurnTable<u8> = DirectCodexActiveTurnTable::new();
let key = std::path::PathBuf::from("C:/projects/direct-turn-demo");
assert_eq!(
table.select(&key, None).expect_err("no active turn"),
"当前项目没有正在运行的陶泥儿回合,无法终止"
);
table.register(key.clone(), "turn-a", 1);
assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a");
assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1);
assert_eq!(
table
.select(&key, Some("turn-b"))
.expect_err("another running turn"),
"正在运行的是另一个陶泥儿回合,已拒绝终止"
);
// 句柄已被后来的回合替换:旧回合收尾不得注销新回合。
table.register(key.clone(), "turn-b", 2);
table.unregister(&key, |value| *value == 1);
assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b");
table.unregister(&key, |value| *value == 2);
assert!(table.select(&key, None).is_err());
}
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
#[test]
fn direct_codex_active_turn_key_normalizes_windows_prefix() {
let root = tempfile::tempdir().expect("temp dir");
let canonical = std::fs::canonicalize(root.path()).expect("canonical root");
let expected = canonical
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(std::path::PathBuf::from)
.unwrap_or(canonical);
let key = direct_codex_active_turn_key(root.path());
assert_eq!(key, expected);
// 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。
assert!(!key.to_string_lossy().starts_with("\\\\?\\"));
}
#[test]
fn direct_item_activities_are_closed_safe_categories() {
let allowed = [
@@ -1989,6 +1989,29 @@ pub(crate) fn write_game_creator_app_config(
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn cancel_direct_codex_turn(
project_path: String,
client_turn_id: Option<String>,
) -> Result<(), String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "agent.kill")?;
cancel_direct_codex_turn_at(root, client_turn_id.as_deref())
}
#[tauri::command]
pub(crate) fn select_game_creator_reasoning_effort(
effort: String,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.llm.reasoning_effort = effort;
persist_game_creator_app_config(config, overlays, false)
}
#[tauri::command]
pub(crate) fn select_game_creator_model(
model_id: String,
@@ -2632,6 +2632,8 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,