修复重进会话被堵死:终止可兜底释放残留守卫,并把拒绝文案改成可操作

cancel_direct_codex_turn 返回值改为 DirectTurnCancelView{outcome, message, clientTurnId},界面能区分"已发中断"与"守卫已被兜底释放"
cancel_direct_codex_turn_at 在拿不到可中断的活句柄(执行进程已退出 / 从没进执行器)时释放本地守卫,返回可读原因;"正在跑的是另一轮"仍保持原拒绝语义,什么都不释放
新增 release_stale_direct_taonier_active_invocation:释放条件写进注释——登记存在、clientTurnId 一致、调用方已确认执行器没有活句柄、"从没进执行器"时登记年龄还要超过 60 秒启动窗口
DirectTaonierStaleGuardReason 给出两种兜底原因与可读文案;DirectCodexActiveTurnTable 增加 registered_client_turn_id,另一轮拒绝文案提成共用常量
把"已有另一条 Direct 客户端回合正在运行"补成可操作文案:可在输入盒点终止,或等它结束后再发送(不改错误分类与前缀语义)
新增用例:同项目第二次进入仍被拒、clientTurnId 不匹配不释放、启动窗口内不释放、老过窗口释放后可再次进入、原守卫 Drop 不影响后来登记的回合
This commit is contained in:
2026-09-15 20:33:01 +08:00
parent 48b482afaf
commit d833ca9d32
4 changed files with 271 additions and 16 deletions
@@ -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,7 +263,9 @@ fn direct_taonier_regeneration_invocation_sha256(invocation_id: &str) -> String
#[derive(Debug)]
struct DirectTaonierActiveInvocation {
invocation_id: String,
/// 登记时刻(Unix 毫秒)。只读探测与"终止"兜底都用它判断这一轮的年龄
/// 登记时刻(Unix 毫秒)。只用于判断一条登记的年龄:见
/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`] 与
/// [`release_stale_direct_taonier_active_invocation`]。
started_at_ms: u64,
}
@@ -271,6 +273,12 @@ 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
}
@@ -297,8 +305,9 @@ impl DirectTaonierActiveInvocationGuard {
"{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId"
)
} else {
"当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份"
.to_string()
format!(
"当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送"
)
});
}
None => {
@@ -364,7 +373,8 @@ pub(crate) struct DirectActiveTurnView {
/// 只读探测:该项目当前登记的 Direct 活跃回合(`None` = 没有回合在跑)。
///
/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop` 释放。
/// 只读,不改变互斥语义与释放时机:守卫仍然只由回合自己的 `Drop`
/// [`release_stale_direct_taonier_active_invocation`] 释放。
pub(crate) fn read_direct_taonier_active_invocation_at(
root: &Path,
) -> Result<Option<DirectActiveTurnView>, String> {
@@ -392,6 +402,73 @@ pub(crate) fn read_direct_codex_active_turn(
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
@@ -4854,6 +4931,96 @@ mod tests {
);
}
#[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())