Merge branch 'fix/resume-running-direct-turn' into feat/chat-codex-ui
This commit is contained in:
@@ -36,7 +36,7 @@ mod skill_pack;
|
||||
use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
|
||||
direct_game_creator_home_codex_chat,
|
||||
direct_game_creator_home_codex_chat, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
|
||||
@@ -3105,13 +3105,23 @@ impl<T> DirectCodexActiveTurnTable<T> {
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if active.0 != expected {
|
||||
return Err("正在运行的是另一个陶泥儿回合,已拒绝终止".to_string());
|
||||
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
Ok(active)
|
||||
}
|
||||
|
||||
/// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。
|
||||
fn registered_client_turn_id(&self, key: &Path) -> Option<&str> {
|
||||
self.entries
|
||||
.get(key)
|
||||
.map(|(client_turn_id, _)| client_turn_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。
|
||||
const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止";
|
||||
|
||||
/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。
|
||||
///
|
||||
/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出
|
||||
@@ -3170,28 +3180,106 @@ fn register_active_direct_codex_turn(
|
||||
DirectCodexActiveTurnGuard { key, cancellation }
|
||||
}
|
||||
|
||||
/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。
|
||||
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted";
|
||||
/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。
|
||||
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released";
|
||||
|
||||
/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectTurnCancelView {
|
||||
/// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或
|
||||
/// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。
|
||||
pub(crate) outcome: String,
|
||||
/// 给用户看的可读结果。
|
||||
pub(crate) message: String,
|
||||
/// 被终止 / 被释放的 clientTurnId。
|
||||
pub(crate) client_turn_id: String,
|
||||
}
|
||||
|
||||
/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。
|
||||
enum DirectCodexTurnCancelTarget {
|
||||
/// app-server 侧还有活句柄:正常发 `turn/interrupt`。
|
||||
Interrupt(Arc<CodexTurnStartCancellation>),
|
||||
/// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。
|
||||
Stale(DirectTaonierStaleGuardReason),
|
||||
}
|
||||
|
||||
/// 终止当前项目正在运行的 Direct 回合。
|
||||
///
|
||||
/// 只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回
|
||||
/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回
|
||||
/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回),
|
||||
/// 不动任何既有事件或命令语义。
|
||||
///
|
||||
/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。
|
||||
/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后
|
||||
/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。
|
||||
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
|
||||
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
|
||||
/// 保持原拒绝语义,什么都不释放。
|
||||
pub(crate) fn cancel_direct_codex_turn_at(
|
||||
root: &Path,
|
||||
client_turn_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<DirectTurnCancelView, String> {
|
||||
let key = direct_codex_active_turn_key(root);
|
||||
let cancellation = {
|
||||
let expected = client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
{
|
||||
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());
|
||||
if let (Some(registered), Some(expected)) =
|
||||
(entries.registered_client_turn_id(&key), expected)
|
||||
{
|
||||
if registered != expected {
|
||||
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = {
|
||||
let entries = direct_codex_active_turns()
|
||||
.lock()
|
||||
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
|
||||
match entries.select(&key, client_turn_id) {
|
||||
Ok((_, cancellation)) if cancellation.app_server_alive() => {
|
||||
DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation))
|
||||
}
|
||||
Ok(_) => {
|
||||
DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited)
|
||||
}
|
||||
Err(_) => DirectCodexTurnCancelTarget::Stale(
|
||||
DirectTaonierStaleGuardReason::NeverReachedExecutor,
|
||||
),
|
||||
}
|
||||
Arc::clone(cancellation)
|
||||
};
|
||||
cancellation.cancel();
|
||||
Ok(())
|
||||
match target {
|
||||
DirectCodexTurnCancelTarget::Interrupt(cancellation) => {
|
||||
cancellation.cancel();
|
||||
Ok(DirectTurnCancelView {
|
||||
outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(),
|
||||
message: "已向正在运行的回合发出终止".to_string(),
|
||||
client_turn_id: client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
DirectCodexTurnCancelTarget::Stale(reason) => {
|
||||
let released =
|
||||
release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?;
|
||||
Ok(DirectTurnCancelView {
|
||||
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
|
||||
message: format!(
|
||||
"{},已释放这一轮的占用,可以直接重新发送消息",
|
||||
reason.message()
|
||||
),
|
||||
client_turn_id: released,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexThreadLease {
|
||||
|
||||
@@ -263,12 +263,26 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String
|
||||
#[derive(Debug)]
|
||||
struct DirectTaonierActiveInvocation {
|
||||
invocation_id: String,
|
||||
/// 登记时刻(Unix 毫秒)。只用于判断一条登记的年龄:见
|
||||
/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`] 与
|
||||
/// [`release_stale_direct_taonier_active_invocation`]。
|
||||
started_at_ms: u64,
|
||||
}
|
||||
|
||||
static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock<
|
||||
Mutex<HashMap<PathBuf, DirectTaonierActiveInvocation>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被
|
||||
/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、
|
||||
/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮
|
||||
/// 不可能再进入执行器,守卫是残留。
|
||||
const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000;
|
||||
|
||||
fn direct_taonier_active_now_millis() -> u64 {
|
||||
unix_millis().min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DirectTaonierActiveInvocationGuard {
|
||||
root: PathBuf,
|
||||
@@ -291,8 +305,9 @@ impl DirectTaonierActiveInvocationGuard {
|
||||
"{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId"
|
||||
)
|
||||
} else {
|
||||
"当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份"
|
||||
.to_string()
|
||||
format!(
|
||||
"当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送"
|
||||
)
|
||||
});
|
||||
}
|
||||
None => {
|
||||
@@ -300,6 +315,7 @@ impl DirectTaonierActiveInvocationGuard {
|
||||
root.clone(),
|
||||
DirectTaonierActiveInvocation {
|
||||
invocation_id: invocation_id.to_string(),
|
||||
started_at_ms: direct_taonier_active_now_millis(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -345,6 +361,114 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result<Stri
|
||||
})
|
||||
}
|
||||
|
||||
/// `read_direct_codex_active_turn` 的返回值:前端重进会话时用它恢复正在跑的回合。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectActiveTurnView {
|
||||
/// 与事件 `turnId` 同一个身份(守卫里登记的 `invocation_id`)。
|
||||
pub(crate) client_turn_id: String,
|
||||
/// 这一轮登记的时刻(Unix 毫秒)。
|
||||
pub(crate) started_at: u64,
|
||||
}
|
||||
|
||||
/// 只读探测:该项目当前登记的 Direct 活跃回合(`None` = 没有回合在跑)。
|
||||
///
|
||||
/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 或
|
||||
/// [`release_stale_direct_taonier_active_invocation`] 释放。
|
||||
pub(crate) fn read_direct_taonier_active_invocation_at(
|
||||
root: &Path,
|
||||
) -> Result<Option<DirectActiveTurnView>, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?;
|
||||
Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map_err(|_| "Direct 调用身份锁已损坏".to_string())?
|
||||
.get(&root)
|
||||
.map(|active| DirectActiveTurnView {
|
||||
client_turn_id: active.invocation_id.clone(),
|
||||
started_at: active.started_at_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 只读命令:读取该项目当前登记的 Direct 活跃回合,供前端重进会话时恢复。
|
||||
///
|
||||
/// 不进入、不抢占、不释放守卫;没有回合时返回 `null`。
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_direct_codex_active_turn(
|
||||
project_path: String,
|
||||
) -> Result<Option<DirectActiveTurnView>, String> {
|
||||
read_direct_taonier_active_invocation_at(Path::new(project_path.trim()))
|
||||
}
|
||||
|
||||
/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DirectTaonierStaleGuardReason {
|
||||
/// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。
|
||||
ExecutorExited,
|
||||
/// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。
|
||||
NeverReachedExecutor,
|
||||
}
|
||||
|
||||
impl DirectTaonierStaleGuardReason {
|
||||
pub(crate) fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::ExecutorExited => "陶泥儿执行进程已退出",
|
||||
Self::NeverReachedExecutor => "这一轮 Direct 回合没有进入执行器",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。
|
||||
///
|
||||
/// 释放条件(四条必须同时成立,这段注释就是契约):
|
||||
/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮;
|
||||
/// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合;
|
||||
/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立;
|
||||
/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过
|
||||
/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段"
|
||||
/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。
|
||||
///
|
||||
/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的;
|
||||
/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。
|
||||
pub(crate) fn release_stale_direct_taonier_active_invocation(
|
||||
root: &Path,
|
||||
expected_client_turn_id: Option<&str>,
|
||||
reason: DirectTaonierStaleGuardReason,
|
||||
) -> Result<String, String> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?;
|
||||
let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?;
|
||||
let Some(existing) = active.get(&root) else {
|
||||
return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string());
|
||||
};
|
||||
if let Some(expected) = expected_client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if existing.invocation_id != expected {
|
||||
return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string());
|
||||
}
|
||||
}
|
||||
if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor {
|
||||
let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at_ms);
|
||||
if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS {
|
||||
return Err(format!(
|
||||
"这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试",
|
||||
age_ms / 1000
|
||||
));
|
||||
}
|
||||
}
|
||||
let released = existing.invocation_id.clone();
|
||||
active.remove(&root);
|
||||
Ok(released)
|
||||
}
|
||||
|
||||
fn direct_taonier_regeneration_project_id(root: &Path) -> Result<String, String> {
|
||||
let project_id = read_manifest(&root.join(".agent/manifest.json"))?
|
||||
.project_id
|
||||
@@ -4771,6 +4895,132 @@ mod tests {
|
||||
.expect("lost-response replay after the original turn finishes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() {
|
||||
let root = tempfile::tempdir().expect("active invocation root");
|
||||
assert_eq!(
|
||||
read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"),
|
||||
None
|
||||
);
|
||||
|
||||
let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1")
|
||||
.expect("first client turn");
|
||||
let running = read_direct_taonier_active_invocation_at(root.path())
|
||||
.expect("read running project")
|
||||
.expect("running turn is visible to the read-only probe");
|
||||
assert_eq!(running.client_turn_id, "client-turn-read-1");
|
||||
assert!(running.started_at > 0, "{running:?}");
|
||||
// camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。
|
||||
assert_eq!(
|
||||
serde_json::to_value(&running).expect("serialize view"),
|
||||
serde_json::json!({
|
||||
"clientTurnId": "client-turn-read-1",
|
||||
"startedAt": running.started_at,
|
||||
})
|
||||
);
|
||||
// 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。
|
||||
let duplicate =
|
||||
DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2")
|
||||
.expect_err("read-only probe must not take over the project");
|
||||
assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX));
|
||||
|
||||
drop(first);
|
||||
assert_eq!(
|
||||
read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() {
|
||||
let root = tempfile::tempdir().expect("active invocation root");
|
||||
let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1")
|
||||
.expect("first client turn");
|
||||
|
||||
// ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。
|
||||
let mismatch = release_stale_direct_taonier_active_invocation(
|
||||
root.path(),
|
||||
Some("client-turn-stale-2"),
|
||||
DirectTaonierStaleGuardReason::ExecutorExited,
|
||||
)
|
||||
.expect_err("another turn must not be released");
|
||||
assert!(mismatch.contains("另一条"), "{mismatch}");
|
||||
assert!(
|
||||
DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err()
|
||||
);
|
||||
|
||||
// ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。
|
||||
let young = release_stale_direct_taonier_active_invocation(
|
||||
root.path(),
|
||||
Some("client-turn-stale-1"),
|
||||
DirectTaonierStaleGuardReason::NeverReachedExecutor,
|
||||
)
|
||||
.expect_err("a freshly registered turn is still starting");
|
||||
assert!(young.contains("暂不能强制释放"), "{young}");
|
||||
|
||||
// ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。
|
||||
backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1);
|
||||
let released = release_stale_direct_taonier_active_invocation(
|
||||
root.path(),
|
||||
Some("client-turn-stale-1"),
|
||||
DirectTaonierStaleGuardReason::NeverReachedExecutor,
|
||||
)
|
||||
.expect("stale guard is released");
|
||||
assert_eq!(released, "client-turn-stale-1");
|
||||
let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2")
|
||||
.expect("a new turn can start once the stale guard is released");
|
||||
// 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。
|
||||
drop(first);
|
||||
let still_running = read_direct_taonier_active_invocation_at(root.path())
|
||||
.expect("read running project")
|
||||
.expect("the newer turn survives the stale guard drop");
|
||||
assert_eq!(still_running.client_turn_id, "client-turn-stale-2");
|
||||
drop(second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_guard_release_after_the_executor_exited_frees_the_project() {
|
||||
let root = tempfile::tempdir().expect("active invocation root");
|
||||
let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1")
|
||||
.expect("client turn");
|
||||
let released = release_stale_direct_taonier_active_invocation(
|
||||
root.path(),
|
||||
None,
|
||||
DirectTaonierStaleGuardReason::ExecutorExited,
|
||||
)
|
||||
.expect("executor exited: this guard is residue");
|
||||
assert_eq!(released, "client-turn-exited-1");
|
||||
assert_eq!(
|
||||
read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"),
|
||||
None
|
||||
);
|
||||
let _second =
|
||||
DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2")
|
||||
.expect("a new turn can start after the residue is released");
|
||||
drop(first);
|
||||
|
||||
// 没有任何登记时给出可读原因,而不是静默成功。
|
||||
let empty = tempfile::tempdir().expect("empty invocation root");
|
||||
let nothing = release_stale_direct_taonier_active_invocation(
|
||||
empty.path(),
|
||||
None,
|
||||
DirectTaonierStaleGuardReason::ExecutorExited,
|
||||
)
|
||||
.expect_err("nothing to release");
|
||||
assert!(nothing.contains("没有正在运行"), "{nothing}");
|
||||
}
|
||||
|
||||
/// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。
|
||||
fn backdate_active_direct_invocation(root: &Path, age_ms: u64) {
|
||||
let root = root.canonicalize().expect("canonical root");
|
||||
let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS
|
||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
.lock()
|
||||
.expect("active invocation lock");
|
||||
let entry = active.get_mut(&root).expect("registered invocation");
|
||||
entry.started_at_ms = entry.started_at_ms.saturating_sub(age_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_success_reply_is_persisted_once_with_the_stable_client_turn_identity() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -1997,7 +1997,7 @@ pub(crate) fn write_game_creator_app_config(
|
||||
pub(crate) fn cancel_direct_codex_turn(
|
||||
project_path: String,
|
||||
client_turn_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<DirectTurnCancelView, 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())
|
||||
|
||||
@@ -2669,6 +2669,7 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
chat_with_game_creator_direct_codex,
|
||||
read_direct_codex_active_turn,
|
||||
cancel_direct_codex_turn,
|
||||
select_game_creator_reasoning_effort,
|
||||
start_planning_session_v2,
|
||||
|
||||
@@ -55,6 +55,8 @@ import type {
|
||||
DesignClarificationRequest,
|
||||
DesignEvent,
|
||||
DesignView,
|
||||
DirectActiveTurnView,
|
||||
DirectTurnCancelView,
|
||||
GameCreatorAgentRuntimeUpdateEvent,
|
||||
GameCreatorChatAgentReply,
|
||||
GameCreatorDirectToolCall,
|
||||
@@ -297,6 +299,18 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true;
|
||||
const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
|
||||
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
|
||||
'direct-codex-turn-already-running:';
|
||||
/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */
|
||||
const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER =
|
||||
'当前项目已有另一条 Direct 客户端回合正在运行';
|
||||
/**
|
||||
* 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在,
|
||||
* 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。
|
||||
*/
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000;
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE =
|
||||
'该回合已无响应,可在输入盒点「终止」结束它以继续';
|
||||
const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL =
|
||||
'已恢复正在运行的回合,正在等待陶泥儿的最新进度';
|
||||
|
||||
function isDirectCodexAuthenticationRequired(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -492,6 +506,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
|
||||
.startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同
|
||||
* 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的
|
||||
* 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。
|
||||
*/
|
||||
export function isDirectCodexAnotherTurnRunningError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回
|
||||
* (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给
|
||||
@@ -862,6 +886,15 @@ export function App({
|
||||
receivedDirectUpdate: boolean;
|
||||
} | null>(null);
|
||||
const lastDirectCodexActivityRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件,
|
||||
* 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。
|
||||
*/
|
||||
const recoveredDirectCodexTurnRef = useRef<{
|
||||
projectPath: string;
|
||||
turnId: string;
|
||||
} | null>(null);
|
||||
const recoveredDirectCodexTurnTimerRef = useRef<number | null>(null);
|
||||
const directCodexConversationTurnSequenceRef = useRef(0);
|
||||
// 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。
|
||||
// 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。
|
||||
@@ -934,6 +967,7 @@ export function App({
|
||||
}
|
||||
|
||||
function resetDirectCodexTurn() {
|
||||
clearRecoveredDirectCodexTurnWatch();
|
||||
activeDirectCodexTurnRef.current = null;
|
||||
lastDirectCodexActivityRef.current = null;
|
||||
setDirectCodexProgress('');
|
||||
@@ -945,6 +979,106 @@ export function App({
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
}
|
||||
|
||||
/** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */
|
||||
function clearRecoveredDirectCodexTurnWatch() {
|
||||
if (recoveredDirectCodexTurnTimerRef.current !== null) {
|
||||
window.clearTimeout(recoveredDirectCodexTurnTimerRef.current);
|
||||
recoveredDirectCodexTurnTimerRef.current = null;
|
||||
}
|
||||
recoveredDirectCodexTurnRef.current = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧
|
||||
* 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点
|
||||
* 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。
|
||||
* 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。
|
||||
*/
|
||||
function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) {
|
||||
clearRecoveredDirectCodexTurnWatch();
|
||||
recoveredDirectCodexTurnRef.current = { projectPath, turnId };
|
||||
recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => {
|
||||
recoveredDirectCodexTurnTimerRef.current = null;
|
||||
const watch = recoveredDirectCodexTurnRef.current;
|
||||
const activeTurn = activeDirectCodexTurnRef.current;
|
||||
if (
|
||||
!watch ||
|
||||
watch.projectPath !== projectPath ||
|
||||
watch.turnId !== turnId ||
|
||||
activeTurn?.projectPath !== projectPath ||
|
||||
activeTurn.turnId !== turnId ||
|
||||
activeTurn.receivedDirectUpdate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDirectCodexStatus('running');
|
||||
setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE);
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE);
|
||||
}, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重进会话时接管仍在运行的 Direct 回合。
|
||||
*
|
||||
* 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开
|
||||
* 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示
|
||||
* 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。
|
||||
*
|
||||
* 只读探测,不改后端回合本身;探测失败(老二进制没有这个命令、路径读不到)等于没有
|
||||
* 回合,不影响打开项目。
|
||||
*/
|
||||
async function restoreRunningDirectCodexTurn(projectPath: string) {
|
||||
if (!directCodexProductRuntime || !projectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
// 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。
|
||||
if (activeDirectCodexTurnRef.current?.projectPath === projectPath) {
|
||||
return;
|
||||
}
|
||||
let activeView: DirectActiveTurnView | null = null;
|
||||
try {
|
||||
activeView = await invoke<DirectActiveTurnView | null>(
|
||||
'read_direct_codex_active_turn',
|
||||
{ projectPath },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const clientTurnId = activeView?.clientTurnId?.trim();
|
||||
if (!clientTurnId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
localProjectPathRef.current !== projectPath ||
|
||||
activeDirectCodexTurnRef.current?.projectPath === projectPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath,
|
||||
turnId: clientTurnId,
|
||||
// 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence
|
||||
// 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。
|
||||
lastSequence: -1,
|
||||
receivedDirectUpdate: false,
|
||||
};
|
||||
setChatAgentBusy(true);
|
||||
setDirectCodexStatus('running');
|
||||
setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`);
|
||||
setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL);
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
setDirectCodexTransientReply('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
watchRecoveredDirectCodexTurn(projectPath, clientTurnId);
|
||||
}
|
||||
|
||||
/** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */
|
||||
function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) {
|
||||
const normalized = next
|
||||
@@ -1994,6 +2128,19 @@ export function App({
|
||||
}
|
||||
activeTurn.lastSequence = payload.sequence;
|
||||
activeTurn.receivedDirectUpdate = true;
|
||||
// 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。
|
||||
if (
|
||||
recoveredDirectCodexTurnRef.current?.projectPath ===
|
||||
payload.projectPath &&
|
||||
recoveredDirectCodexTurnRef.current.turnId === payload.turnId
|
||||
) {
|
||||
clearRecoveredDirectCodexTurnWatch();
|
||||
setChatComposerNotice((current) =>
|
||||
current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE
|
||||
? ''
|
||||
: current,
|
||||
);
|
||||
}
|
||||
// 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。
|
||||
if (payload.toolCalls?.length) {
|
||||
applyDirectToolCalls(
|
||||
@@ -3670,6 +3817,9 @@ export function App({
|
||||
{ projectPath: nextProjectPath },
|
||||
).catch(() => []);
|
||||
replaceDirectToolCalls(persistedToolCalls);
|
||||
// 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示
|
||||
// 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。
|
||||
await restoreRunningDirectCodexTurn(nextProjectPath);
|
||||
}
|
||||
let supervisorConversation: LocalConversationResult | null = null;
|
||||
let runtime: AgentRuntimeState | null = null;
|
||||
@@ -6702,12 +6852,21 @@ export function App({
|
||||
await refreshDirectProjectManifest(directProjectPath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDirectCodexTurnAlreadyRunningError(error)) {
|
||||
if (
|
||||
isDirectCodexTurnAlreadyRunningError(error) ||
|
||||
isDirectCodexAnotherTurnRunningError(error)
|
||||
) {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||
setProjectSupervisorRuntimeError(
|
||||
'陶泥儿仍在处理这条消息,请稍候刷新对话。',
|
||||
'陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。',
|
||||
);
|
||||
// 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它
|
||||
// (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally
|
||||
// 里 setChatAgentBusy(false) 的复位竞态。
|
||||
window.setTimeout(() => {
|
||||
void restoreRunningDirectCodexTurn(directProjectPath);
|
||||
}, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -12156,11 +12315,30 @@ export function App({
|
||||
setDirectCodexTurnCancelling(true);
|
||||
setChatComposerNotice('正在终止当前回合');
|
||||
try {
|
||||
await invoke('cancel_direct_codex_turn', {
|
||||
projectPath: directProjectPath,
|
||||
clientTurnId: activeTurn.turnId,
|
||||
});
|
||||
const result = await invoke<DirectTurnCancelView>(
|
||||
'cancel_direct_codex_turn',
|
||||
{
|
||||
projectPath: directProjectPath,
|
||||
clientTurnId: activeTurn.turnId,
|
||||
},
|
||||
);
|
||||
const message = result?.message?.trim();
|
||||
if (result?.outcome === 'released') {
|
||||
// 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放
|
||||
// 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡
|
||||
// 与"任务执行中"会一直挂着,用户仍然发不出消息。
|
||||
resetDirectCodexTurn();
|
||||
setChatAgentBusy(false);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setChatComposerNotice(
|
||||
message ?? '已结束这一轮占用,可以直接重新发送消息',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDirectCodexProgress('正在终止当前回合');
|
||||
if (message) {
|
||||
setChatComposerNotice(message);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProjectSupervisorRuntimeError(`终止失败:${message}`);
|
||||
|
||||
@@ -1162,6 +1162,32 @@ export interface GameCreatorDirectTurnUpdateEvent {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `read_direct_codex_active_turn` 的返回值:Rust 进程内当前登记的 Direct 活跃回合。
|
||||
*
|
||||
* 重进会话时用它把前端的"当前活跃回合"接管回来,否则界面不知道有回合在跑,
|
||||
* 既不显示过程卡也不给终止入口,用户再发消息只会被守卫拒绝。
|
||||
*/
|
||||
export interface DirectActiveTurnView {
|
||||
/** 与回合事件的 `turnId` 同一个身份。 */
|
||||
clientTurnId: string;
|
||||
/** 这一轮登记的时刻(Unix 毫秒)。 */
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** `cancel_direct_codex_turn` 的返回值。 */
|
||||
export interface DirectTurnCancelView {
|
||||
/**
|
||||
* `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位;
|
||||
* `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。
|
||||
*/
|
||||
outcome: string;
|
||||
/** 给用户看的可读结果。 */
|
||||
message: string;
|
||||
/** 被终止 / 被释放的 clientTurnId。 */
|
||||
clientTurnId: string;
|
||||
}
|
||||
|
||||
export interface AgentRunControlResult {
|
||||
runId: string;
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user