DirectProject 回合事件补齐用户条目与终态兜底
- 新增 direct_thread_id_for_project:订阅、回合事件写入与兜底释放共用一个线程身份 - 订阅命令改用同一个身份函数,删掉命令层自己那套路径归一 - 用户消息落盘后立刻作为 item.completed 下发,前端用同一个 itemId 合并乐观气泡与历史条目 - 采集终态事件是否已经下发,缺失时按成功 / 失败补一条 turn.completed,避免前端永远停在运行中 - 取消回合的 Stale 分支补一条 turn.completed(aborted)
This commit is contained in:
@@ -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::{
|
||||
|
||||
+18
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user