DirectProject 回合事件补齐用户条目与终态兜底

- 新增 direct_thread_id_for_project:订阅、回合事件写入与兜底释放共用一个线程身份
- 订阅命令改用同一个身份函数,删掉命令层自己那套路径归一
- 用户消息落盘后立刻作为 item.completed 下发,前端用同一个 itemId 合并乐观气泡与历史条目
- 采集终态事件是否已经下发,缺失时按成功 / 失败补一条 turn.completed,避免前端永远停在运行中
- 取消回合的 Stale 分支补一条 turn.completed(aborted)
This commit is contained in:
2026-09-16 22:15:57 +08:00
parent 3c6e084a41
commit bf8f96c115
4 changed files with 69 additions and 15 deletions
@@ -41,7 +41,8 @@ use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_thread_id_for_project, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -20,6 +20,24 @@ pub(crate) fn direct_codex_canonical_project_identity(
))
}
/// 项目根目录在 Thread Manager 里的线程身份。
///
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
/// 一个永不产生事件的空线程;因此这里统一做一次权威路径归一。归一失败(例如 manifest
/// 暂时读不到)时退回调用方给的路径,至少保持同一入口同一结果。
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
let Ok((canonical_root, _)) = direct_codex_canonical_project_identity(root) else {
return root.to_string_lossy().into_owned();
};
canonical_root
.to_str()
.and_then(|value| value.strip_prefix(r"\\?\"))
.map(std::path::Path::new)
.unwrap_or(canonical_root.as_path())
.to_string_lossy()
.into_owned()
}
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)]
{
@@ -13,8 +13,11 @@ use uuid::Uuid;
mod direct_project_history_wire;
use direct_project_history_wire::build_direct_project_history_injection_params;
mod direct_project_identity;
pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands;
use direct_project_identity::*;
pub(crate) use direct_project_identity::{
direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands,
direct_thread_id_for_project,
};
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";
@@ -2821,6 +2824,9 @@ impl CodexAppServerConnection {
.map(str::trim)
.filter(|turn_id| !turn_id.is_empty())
.map(str::to_string);
// 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发,
// 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。
let mut direct_persisted_user_item: Option<serde_json::Value> = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_prompt = direct_codex_current_user_prompt(&request).trim();
if current_prompt.is_empty() {
@@ -2844,6 +2850,7 @@ impl CodexAppServerConnection {
};
append_direct_project_user_message_at(history_root, &user_item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_persisted_user_item = Some(user_item);
}
}
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
@@ -2955,9 +2962,17 @@ impl CodexAppServerConnection {
}
};
turn_start_guard.armed = false;
let direct_thread_id = history_root.to_string_lossy().into_owned();
let direct_thread_id = direct_thread_id_for_project(history_root);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
);
}
}
}
let mut receiver = self.register_turn(&turn_id).await;
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
@@ -2975,6 +2990,9 @@ impl CodexAppServerConnection {
game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms);
let hard_deadline =
tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms);
// 记录 app-server 是否已经给出终态:`turn/completed` 只在收到被动 terminal
// 事件时下发,超时 / 传输中断 / 协议错误都走不到那里。
let mut terminal_recorded = false;
let collect = async {
let mut final_text = None;
let mut streamed_text = String::new();
@@ -3263,6 +3281,7 @@ impl CodexAppServerConnection {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
&& matches!(status, "completed" | "interrupted" | "failed")
{
terminal_recorded = true;
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status.to_string()),
@@ -3306,7 +3325,26 @@ impl CodexAppServerConnection {
}
}
};
let text = match collect.await {
let collect_result = collect.await;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
&& !terminal_recorded
{
// 兜底终态:没有这条事件,前端的"最新回合是否在跑"会一直停在运行中。
// 事件日志本身不完美(见 `ThreadState::lifecycle_anchor` 的 TODO),
// 但"这一轮有没有结束"必须有终态事件。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(
if collect_result.is_ok() {
"completed"
} else {
"failed"
}
.to_string(),
),
);
}
let text = match collect_result {
Ok(text) => text,
Err(error) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
@@ -3553,6 +3591,12 @@ pub(crate) fn cancel_direct_codex_turn_at(
DirectCodexTurnCancelTarget::Stale(reason) => {
let released =
release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?;
// 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器),
// 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。
append_direct_thread_event(
&direct_thread_id_for_project(root),
DirectThreadEvent::turn_completed("aborted".to_string()),
);
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
message: format!(
@@ -1,8 +1,5 @@
use super::*;
use crate::agent::{
direct_codex_canonical_project_identity, read_direct_project_chat_history_at,
read_direct_project_last_item_id_at,
};
use crate::agent::{read_direct_project_chat_history_at, read_direct_project_last_item_id_at};
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
@@ -5320,13 +5317,7 @@ pub(crate) async fn subscribe_direct_project_thread(
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?;
let thread_root = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(Path::new)
.unwrap_or(canonical_root.as_path());
let thread_id = thread_root.to_string_lossy().into_owned();
let thread_id = direct_thread_id_for_project(root);
let mut bootstrap = subscribe_direct_thread(&thread_id);
if bootstrap.last_completed_item_id.is_none() {
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;