From bd78a91e555fb4d7b067e7ee8479b8094e24a770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 16:08:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E6=8B=92=E5=8D=95=E7=9A=84=E4=B8=A4=E4=B8=AA=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread Manager 的 `accept_turn` 在并发冲突时回**已有的 `turn_id`**(调用方的 `clientTurnId`),不再回进程内的占用 token - `DirectTurnReservation::accept` 的拒单载荷改成 `existing`=已在跑那一轮的 `clientTurnId`、`incoming`=本次请求的 `clientTurnId`;同一轮重发时两者相等,"同一轮消息仍在处理中"那条文案才走得到 - 补 `accept_conflict_reports_client_turn_ids_not_reservation_tokens`(同一轮 / 另一轮两条分支都钉住)与 `accept_conflict_returns_the_existing_turn_id`(manager 侧只回回合身份) - 占用对象自己的 token 保持 UUID 不变(`complete_direct_thread_turn_if_reserved` 靠它配对),改的只有错误载荷 --- .../src/agent/direct_thread_manager.rs | 27 ++++++++-- .../src-tauri/src/agent/direct_turn_accept.rs | 51 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 681399347..2b624df55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -200,8 +200,10 @@ impl DirectThreadManager { /// 接单:同一个临界区里拒绝并发、登记占用、追加逻辑回合开始事件。 /// - /// 返回 `Err(existing_token)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, - /// 由调用方把它投影成接单拒绝。 + /// 返回 `Err(existing_turn_id)` 表示这个 thread 已经有一条没收口的回合——此时不动队列, + /// 由调用方把它投影成接单拒绝。回的是**回合身份**(调用方接单时给的 `turn_id`)而不是占用 + /// `token`:占用 token 只活在这个进程里,界面拿它匹配不了自己发出的那一轮,也没法判断 + /// "撞的是同一轮还是另一轮"。 fn accept_turn( &mut self, thread_id: &str, @@ -213,7 +215,7 @@ impl DirectThreadManager { { let thread = self.threads.entry(thread_id.to_string()).or_default(); if let Some(active) = thread.active_turn.as_ref() { - return Err(active.token.clone()); + return Err(active.turn_id.clone()); } thread.active_turn = Some(ActiveDirectTurn { token: token.to_string(), @@ -562,6 +564,9 @@ pub(crate) fn append_direct_thread_event( } /// 接单:拒绝并发 + 登记占用 + 发逻辑回合开始事件(见 [`DirectThreadManager::accept_turn`])。 +/// `Err` 是这一轮**已有的回合身份**(`turn_id`,也就是调用方的 `clientTurnId`),不是占用 token: +/// 调用方拿它投影成 `TurnAlreadyRunning` 的两个身份字段,界面按"撞的是同一轮还是另一轮"决定要 +/// 不要动当前回合。 pub(crate) fn accept_direct_thread_turn( thread_id: &str, token: &str, @@ -1072,4 +1077,20 @@ mod tests { ); assert!(snapshot_of(&manager, thread_id).is_none()); } + + /// 并发接单回给调用方的是**回合身份**(`turn_id`),不是占用 `token`:token 只活在这个进程 + /// 里,界面拿它匹配不了自己发出的那一轮。 + #[test] + fn accept_conflict_returns_the_existing_turn_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager + .accept_turn("thread-1", "token-1", "turn-1", None, FIXED_AT_MS) + .expect("accept"); + + let conflict = manager + .accept_turn("thread-1", "token-2", "turn-2", None, FIXED_AT_MS) + .err(); + + assert_eq!(conflict.as_deref(), Some("turn-1")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs index b7d0c4ca9..fce538000 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs @@ -32,6 +32,10 @@ impl DirectTurnReservation { /// 失败表示这个 thread 已经有一条没收口的回合(并发接单),此时不改队列、不发事件。 /// `client_turn_id` 是给界面看的回合身份(首页快照与进度回填按它匹配),与占用身份 `token` /// 是两件事:前者来自调用方,后者只活在这个进程里。 + /// + /// 拒单载荷里的两个身份都取**回合身份**:`existing` 是已在跑的那一轮的 `clientTurnId` + /// (Thread Manager 回的就是它),`incoming` 是本次请求的 `clientTurnId`。同一轮重发时两者 + /// 相等,界面才走得到"同一轮消息仍在处理中"那条文案。 pub(crate) fn accept( thread_id: &str, client_turn_id: &str, @@ -47,7 +51,7 @@ impl DirectTurnReservation { ) .map_err(|existing| DirectTurnError::TurnAlreadyRunning { existing_invocation_id: existing, - incoming_invocation_id: token.clone(), + incoming_invocation_id: client_turn_id.to_string(), })?; Ok(Self { thread_id: thread_id.to_string(), @@ -152,6 +156,51 @@ mod tests { drop(reservation); } + /// 拒单载荷里的两个身份都是**回合身份**:撞的是同一轮时两者相等,界面才走得到"同一轮消息仍在 + /// 处理中";撞的是另一轮时两者不等,界面才敢提示"另一条回合在运行"。占用 token 只活在本进程, + /// 一旦漏进载荷,这两个分支就都判不出来(UUID 永远不等于界面的 `clientTurnId`)。 + #[test] + fn accept_conflict_reports_client_turn_ids_not_reservation_tokens() { + let thread = unique_thread("same-turn-conflict"); + let reservation = + DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")).expect("accept"); + + let same_turn = match DirectTurnReservation::accept(&thread, "turn-1", Some("u-1")) { + Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), + Err(error) => error, + }; + let DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } = &same_turn + else { + panic!("expected a concurrency rejection, got {same_turn:?}"); + }; + assert_eq!(existing_invocation_id, "turn-1"); + assert_eq!(incoming_invocation_id, "turn-1"); + assert!( + same_turn.to_string().contains("同一轮消息仍在处理中"), + "{same_turn}" + ); + + let other_turn = match DirectTurnReservation::accept(&thread, "turn-2", Some("u-2")) { + Ok(_) => panic!("同一 thread 的第二条回合必须被拒"), + Err(error) => error, + }; + assert!(matches!( + &other_turn, + DirectTurnError::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } if existing_invocation_id == "turn-1" && incoming_invocation_id == "turn-2" + )); + assert!( + other_turn.to_string().contains("另一条 Direct 客户端回合"), + "{other_turn}" + ); + drop(reservation); + } + #[test] fn drop_without_a_terminal_writes_a_host_dropped_terminal() { let thread = unique_thread("drop");