diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f183e0f6b..02b48e807 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,8 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); -pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< - std::sync::Mutex>, +/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。 +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock< + std::sync::Mutex>, > = OnceLock::new(); #[cfg(test)] pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = @@ -293,8 +294,8 @@ pub(crate) use entrypoints::{ configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event, generate_local_game_draft_at, - install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, - read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, + read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4038f1bcb..4e5177b10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,11 +1,12 @@ use super::*; const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; +const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; -fn lock_game_creator_manifest_invalidation_event_sink( -) -> std::sync::MutexGuard<'static, Option> { - GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK - .get_or_init(|| Mutex::new(None)) +fn lock_game_creator_manifest_invalidation_event_sinks( +) -> std::sync::MutexGuard<'static, Vec> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS + .get_or_init(|| Mutex::new(Vec::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } @@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( token: &str, ) -> Result<(), String> { let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; - install_game_creator_manifest_invalidation_event_sink(sink); + register_game_creator_manifest_invalidation_event_sink(sink); Ok(()) } @@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink( }) } -pub(crate) fn install_game_creator_manifest_invalidation_event_sink( +/// 登记一个界面窗口的事件接收端。 +/// +/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有 +/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。 +pub(crate) fn register_game_creator_manifest_invalidation_event_sink( sink: GameCreatorManifestInvalidationEventSink, ) { - *lock_game_creator_manifest_invalidation_event_sink() = Some(sink); + let mut sinks = lock_game_creator_manifest_invalidation_event_sinks(); + if let Some(existing) = sinks + .iter_mut() + .find(|existing| existing.token == sink.token) + { + *existing = sink; + return; + } + if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX { + sinks.remove(0); + } + sinks.push(sink); +} + +fn remove_game_creator_manifest_invalidation_event_sink(token: &str) { + lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token); } #[cfg(test)] @@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard { } pub(crate) fn configured_sink(&self) -> Option { - lock_game_creator_manifest_invalidation_event_sink().clone() + lock_game_creator_manifest_invalidation_event_sinks() + .first() + .cloned() + } + + pub(crate) fn configured_sinks(&self) -> Vec { + lock_game_creator_manifest_invalidation_event_sinks().clone() } } #[cfg(test)] impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { fn drop(&mut self) { - *lock_game_creator_manifest_invalidation_event_sink() = None; + lock_game_creator_manifest_invalidation_event_sinks().clear(); } } @@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( } fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { - let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); - let Some(sink) = sink else { + let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone(); + if sinks.is_empty() { return Ok(()); + } + let event = GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), }; + let mut failed_tokens = Vec::new(); + let mut last_error = None; + for sink in &sinks { + match relay_game_creator_manifest_invalidation_to_sink(sink, &event) { + Ok(()) => {} + Err(error) => { + // 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。 + failed_tokens.push(sink.token.clone()); + last_error = Some(error); + } + } + } + if !failed_tokens.is_empty() { + lock_game_creator_manifest_invalidation_event_sinks() + .retain(|sink| !failed_tokens.contains(&sink.token)); + } + match last_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +fn relay_game_creator_manifest_invalidation_to_sink( + sink: &GameCreatorManifestInvalidationEventSink, + event: &GameCreatorManifestInvalidatedEvent, +) -> Result<(), String> { let envelope = GameCreatorManifestInvalidationRelayEnvelope { - token: sink.token, - event: GameCreatorManifestInvalidatedEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id: agent_id.to_string(), - }, + token: sink.token.clone(), + event: event.clone(), }; let payload = serde_json::to_vec(&envelope) .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index dc26656c2..ea8d647e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2219,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) enum GameCreatorGuiRunnerShutdownOutcome { NotRequested, Requested, + /// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。 + Retained, Failed(GameCreatorGuiRunnerShutdownFailure), } @@ -2256,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error( GameCreatorGuiRunnerShutdownFailure::ProcessIdentity } else if error.contains("当前平台不支持") || error.contains("macOS 不提供") { GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported - } else if error.contains("实例锁") || error.contains("owner 锁") { + } else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁") + { GameCreatorGuiRunnerShutdownFailure::LockTimeout } else if error.contains("endpoint") { GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable @@ -2272,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown( shutdown: F, ) -> GameCreatorGuiRunnerShutdownOutcome where - F: FnOnce() -> Result<(), String>, + F: FnOnce() -> Result, { if !game_creator_gui_run_event_requests_runner_shutdown(event) { return GameCreatorGuiRunnerShutdownOutcome::NotRequested; } match shutdown() { - Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained, Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed( classify_game_creator_gui_runner_shutdown_error(&error), ), @@ -2298,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } } - match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { app_log!("agent.runner.gui_exit.shutdown_requested") } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) } @@ -2611,27 +2621,25 @@ fn main() { ) })?; setup_log.append("startup.runner.configure.complete"); - let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + hold_external_agent_runner_gui_participant_lock(&config_dir) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( - "startup.runner.owner-lock.failed details={details}" + "startup.runner.participant-lock.failed details={details}" )); }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("获取 GUI owner 锁失败:{error}"), + format!("建立 AGC 界面参与锁失败:{error}"), ) })?; - let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); - app.manage(gui_owner_lock); setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); set_direct_thread_manager_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index be22c4d96..71fd5bf4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,21 +12,20 @@ pub(crate) use client::{ clear_external_agent_runner_platform_session, compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, - ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, + ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock, + install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, - shutdown_external_agent_runner_if_idle, steer_external_agent_runner, - wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, + steer_external_agent_runner, wake_external_agent_runner_pending, + wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; -pub(crate) use endpoint::{ - acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled, - external_agent_runner_is_server_process, -}; +pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; pub(crate) use server::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 6c6f9fdbf..12561a7f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { struct ExternalAgentRunnerGuiOwnerRegistration { generation: u64, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, config_dir: PathBuf, params: ExternalAgentRunnerRequestParams, attached_boot_id: Option, } +/// claim 解析模式。 +/// +/// `Adopt` 用于窗口启动:沿用现有 durable claim,只有 claim 缺失或不可读时才发布。 +/// `Publish` 用于本窗口改动了平台登录态:发布新 epoch,成为新的登录态权威。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerGuiOwnerClaimMode { + Adopt, + Publish, +} + static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< + Mutex>, +> = OnceLock::new(); + fn external_agent_runner_gui_owner_attachment_state( ) -> &'static Mutex { EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +fn external_agent_runner_gui_participant_lock( +) -> &'static Mutex> { + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) +} + +/// 取得并持有本窗口的界面参与锁,直到窗口退出。 +/// +/// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 +/// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +pub(crate) fn hold_external_agent_runner_gui_participant_lock( + config_dir: &Path, +) -> Result<(), String> { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)?; + *lock_unpoisoned(external_agent_runner_gui_participant_lock()) = Some(lock); + Ok(()) +} + +fn release_external_agent_runner_gui_participant_lock() { + drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); +} + +/// 登记本窗口的 owner claim 与 attach 参数。 +/// +/// 这里不做 claim 文件 IO:claim 由调用方按 `claim_mode` 解析后写进 `params`, +/// 因此该函数可以在没有真实 AppData 的单元测试里使用。 pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, mut params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; - params.gui_owner_session_revision = Some(generation); - if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() { - write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?; + if params.gui_owner_session_revision.is_none() { + params.gui_owner_session_revision = Some(generation); } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, + claim_mode, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, @@ -61,6 +102,29 @@ pub(super) fn register_external_agent_runner_gui_owner_attachment( Ok(()) } +pub(super) fn reserve_external_agent_runner_gui_owner_claim_revision( + state: &Mutex, +) -> u64 { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + state.generation +} + +pub(super) fn resolve_external_agent_runner_gui_owner_claim( + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, + session_revision: u64, +) -> Result { + match claim_mode { + ExternalAgentRunnerGuiOwnerClaimMode::Adopt => { + adopt_or_publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + ExternalAgentRunnerGuiOwnerClaimMode::Publish => { + publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + } +} + pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( state: &Mutex, config_dir: &Path, @@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with Result<(), String> where - F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, + F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, { - let Some((generation, params)) = ({ - let state = lock_unpoisoned(state); - state.registration.as_ref().and_then(|registration| { - (registration.config_dir == config_dir - && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) - .then(|| (registration.generation, registration.params.clone())) - }) - }) else { - return Ok(()); - }; + const ATTACH_CLAIM_RETRY_LIMIT: usize = 3; + let mut last_claim_error = None; + for attempt in 0..ATTACH_CLAIM_RETRY_LIMIT { + let Some((generation, params, claim_mode)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| { + ( + registration.generation, + registration.params.clone(), + registration.claim_mode, + ) + }) + }) + }) else { + return Ok(()); + }; - attach(endpoint, params)?; - - let mut state = lock_unpoisoned(state); - if let Some(registration) = state.registration.as_mut() { - if registration.generation == generation && registration.config_dir == config_dir { - registration.attached_boot_id = Some(endpoint.boot_id.clone()); + match attach(endpoint, params) { + Ok(()) => { + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation + && registration.config_dir == config_dir + { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + return Ok(()); + } + Err(error) if attempt + 1 < ATTACH_CLAIM_RETRY_LIMIT && error.contains("claim") => { + // 另一个窗口在本次 attach 前后发布了新 claim:按最新 claim 重新解析后重试。 + last_claim_error = Some(error); + refresh_registered_external_agent_runner_gui_owner_claim( + state, config_dir, claim_mode, + )?; + } + Err(error) => return Err(error), } } + Err(last_claim_error.unwrap_or_else(|| "Agent Runner attach 重试后仍然失败".to_string())) +} + +fn refresh_registered_external_agent_runner_gui_owner_claim( + state: &Mutex, + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, +) -> Result<(), String> { + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision(state); + let claim = + resolve_external_agent_runner_gui_owner_claim(config_dir, claim_mode, session_revision)?; + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return Ok(()); + }; + if registration.config_dir != config_dir { + return Ok(()); + } + registration.claim_mode = claim_mode; + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + registration.attached_boot_id = None; Ok(()) } @@ -963,7 +1072,6 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, - gui_owner_epoch: &str, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); @@ -971,13 +1079,25 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; let platform_session = crate::current_platform_session(); + // 启动阶段先采纳现有 durable claim:第二个及后续窗口与第一个窗口共享同一 + // epoch,因此不会被判定为抢走登录态权威;claim 缺失或不可读时才发布新 claim。 + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision( + external_agent_runner_gui_owner_attachment_state(), + ); + let claim = resolve_external_agent_runner_gui_owner_claim( + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + session_revision, + )?; register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), - gui_owner_epoch: Some(gui_owner_epoch.to_string()), + gui_owner_epoch: Some(claim.owner_epoch), + gui_owner_session_revision: Some(claim.session_revision), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), @@ -1107,7 +1227,7 @@ pub(super) fn remember_external_agent_runner_platform_session( state, session, generation, - write_external_agent_runner_gui_owner_claim_atomic, + publish_external_agent_runner_gui_owner_claim, ) } @@ -1115,7 +1235,7 @@ pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, generation: u64, - write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, + publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { @@ -1147,21 +1267,23 @@ pub(super) fn remember_external_agent_runner_platform_session_with( } state.generation = state.generation.wrapping_add(1); let registration_generation = state.generation; - let claim = state.registration.as_ref().and_then(|registration| { - registration - .params - .gui_owner_epoch - .as_deref() - .map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string())) - }); - if let Some((config_dir, owner_epoch)) = claim { - write_claim(&config_dir, &owner_epoch, registration_generation)?; - } + // 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。 + // 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。 + // 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身) + // 不写任何 claim 文件。 + let published_claim = state + .registration + .as_ref() + .filter(|registration| registration.params.gui_owner_epoch.is_some()) + .map(|registration| registration.config_dir.clone()) + .map(|config_dir| publish_claim(&config_dir, registration_generation)) + .transpose()?; let registration = state .registration .as_mut() .expect("checked GUI owner registration must remain present while locked"); registration.generation = registration_generation; + registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish; registration.attached_boot_id = None; registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); registration.params.platform_access_token = @@ -1169,7 +1291,10 @@ pub(super) fn remember_external_agent_runner_platform_session_with( registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); registration.params.platform_auth_generation = Some(generation); - registration.params.gui_owner_session_revision = Some(registration_generation); + if let Some(claim) = published_claim { + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + } Ok(()) } @@ -1263,6 +1388,25 @@ pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result Result { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(true); + }; + release_external_agent_runner_gui_participant_lock(); + if external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))? { + return Ok(false); + } + shutdown_external_agent_runner_at(&config_dir)?; + Ok(true) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -1301,34 +1445,12 @@ pub(super) fn ensure_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { - match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { - ExternalAgentRunnerReuseDecision::Reuse => { - if ping_external_agent_runner(&endpoint).is_ok() { - attach_registered_external_agent_runner_gui_owner_if_needed( - config_dir, &endpoint, - )?; - return Ok(endpoint); - } - } - ExternalAgentRunnerReuseDecision::Retire => { - let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( - &endpoint, - endpoint.protocol_version, - random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, - "runner.ping", - ExternalAgentRunnerRequestParams::default(), - ); - if incompatible_ping.is_ok() { - retire_incompatible_external_agent_runner( - &endpoint_path, - &endpoint, - EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT - .load(std::sync::atomic::Ordering::Acquire), - )?; - } - } - } + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); } let mut launched = launch_external_agent_runner(config_dir)?; match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { @@ -1345,11 +1467,57 @@ pub(super) fn ensure_external_agent_runner( Err(error) => { let _ = launched.child.kill(); let _ = launched.child.wait(); + // 同一 AppData 的另一个窗口可能在这段时间里已经启动了 Runner: + // 实例锁竞争失败不能立刻报成启动失败,先按最新 endpoint 复用一次。 + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); + } Err(error) } } } +fn reuse_or_retire_external_agent_runner_endpoint( + config_dir: &Path, + endpoint_path: &Path, + executable_fingerprint: &str, +) -> Result, String> { + if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { + match external_agent_runner_endpoint_reuse_decision(&endpoint, executable_fingerprint) { + ExternalAgentRunnerReuseDecision::Reuse => { + if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; + return Ok(Some(endpoint)); + } + } + ExternalAgentRunnerReuseDecision::Retire => { + let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + ); + if incompatible_ping.is_ok() { + retire_incompatible_external_agent_runner( + endpoint_path, + &endpoint, + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .load(std::sync::atomic::Ordering::Acquire), + )?; + } + } + } + } + Ok(None) +} + pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index b254c88f9..344d478e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,6 +1,6 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{ - install_game_creator_manifest_invalidation_event_sink, + register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; use serde::Deserialize; @@ -116,9 +116,9 @@ fn apply_external_agent_runner_gui_owner_attachment( .gui_owner_session_revision .ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?; let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?; if durable_claim.owner_epoch != requested_epoch @@ -127,7 +127,14 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 已过期".to_string()); } let requested_claim = (requested_epoch.to_string(), requested_revision); - let replace_claim = active_claim.as_ref() != Some(&requested_claim); + // 同一 AppData 的多个窗口共享同一个 epoch:只有 epoch 变化(本窗口发布了新的 + // 登录态权威)才允许强制替换会话。同一 epoch 内的重复 attach 只做单调校验, + // 因此后开窗口的“无登录态 attach”不会清空已有会话。 + let epoch_changed = match active_claim.as_ref() { + Some(active) => active.0 != requested_epoch, + None => true, + }; + let replace_claim = epoch_changed; let result = match ( params.platform_user_id.as_deref(), params.platform_access_token.as_deref(), @@ -159,7 +166,7 @@ fn apply_external_agent_runner_gui_owner_attachment( crate::clear_platform_session_checked(generation) } } - (None, None, None, None) if replace_claim => { + (None, None, None, None) if epoch_changed => { crate::clear_platform_session_for_gui_owner(0); Ok(()) } @@ -185,7 +192,7 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { - install_game_creator_manifest_invalidation_event_sink(event_sink); + register_game_creator_manifest_invalidation_event_sink(event_sink); } *active_claim = Some(requested_claim); Ok(()) @@ -195,9 +202,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( state: &ExternalAgentRunnerServerState, ) -> Result<(), String> { let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir); let matches = durable_claim.as_ref().is_ok_and(|claim| { @@ -768,7 +775,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( } } "runner.attach_gui_owner" => { - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let event_sink = request .params @@ -810,7 +817,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( Ok(false) => ExternalAgentRunnerResponse::failure( &request.request_id, "gui-owner-missing", - "Agent Runner 未检测到活跃 GUI owner 锁", + "Agent Runner 未检测到活跃的 AGC 界面进程", ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index bfbd56241..de3ade0ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -6,7 +6,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5); +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL: Duration = + Duration::from_millis(40); pub(super) fn external_agent_runner_config_dir_lock() -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_CONFIG_DIR.get_or_init(|| Mutex::new(None)) @@ -305,8 +310,8 @@ pub(super) fn external_agent_runner_lock_path(config_dir: &Path) -> PathBuf { config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME) } -pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf { - config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME) +pub(super) fn external_agent_runner_gui_participant_lock_path(config_dir: &Path) -> PathBuf { + config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME) } pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf { @@ -341,8 +346,9 @@ pub(super) fn read_external_agent_runner_gui_owner_claim( Ok(claim) } -pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result { - match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? { +/// 独占探测:返回 `true` 表示仍有界面进程持有该参与锁。 +pub(super) fn external_agent_runner_lock_is_held(path: &Path) -> Result { + match try_open_external_agent_runner_lock(path, "AGC 界面参与锁")? { Some(lock) => { drop(lock); Ok(false) @@ -743,10 +749,21 @@ pub(super) fn read_current_external_agent_runner_endpoint( }) } +/// 锁文件的两种打开方式。 +/// +/// `Exclusive` 是权威探测:能否独占取得句柄决定“还有没有存活持有者”。 +/// `Shared` 是参与者持有:同一 AppData 的多个界面进程可以同时持有。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerLockMode { + Exclusive, + Shared, +} + #[cfg(unix)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::fd::AsRawFd; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -809,14 +826,30 @@ pub(super) fn try_open_external_agent_runner_lock( path.display() )); } + let flock_operation = match mode { + ExternalAgentRunnerLockMode::Exclusive => libc::LOCK_EX | libc::LOCK_NB, + ExternalAgentRunnerLockMode::Shared => libc::LOCK_SH | libc::LOCK_NB, + }; // SAFETY: flock only observes the valid fd owned by `file`; `file` remains alive on success. - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + let result = unsafe { libc::flock(file.as_raw_fd(), flock_operation) }; if result == 0 { return Ok(Some(file)); } let error = io::Error::last_os_error(); if error.kind() == io::ErrorKind::WouldBlock { - Ok(None) + return match mode { + ExternalAgentRunnerLockMode::Exclusive => Ok(None), + ExternalAgentRunnerLockMode::Shared => Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )), + }; + } + if mode == ExternalAgentRunnerLockMode::Shared { + Err(format!( + "以共享方式获取 {label} 失败:{}: {error}", + path.display() + )) } else { Err(format!( "获取 {label} 系统锁失败:{}: {error}", @@ -825,39 +858,58 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(unix)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + #[cfg(windows)] pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { matches!(error.raw_os_error(), Some(32 | 33)) } #[cfg(windows)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::windows::fs::OpenOptionsExt; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; let parent = path .parent() .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; let runner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); - let gui_owner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME); - if path != runner_lock_path && path != gui_owner_lock_path { + let gui_participant_lock_path = + private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME); + if path != runner_lock_path && path != gui_participant_lock_path { return Err(format!( "{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}", runner_lock_path.display(), - gui_owner_lock_path.display() + gui_participant_lock_path.display() )); } + let share_mode = match mode { + ExternalAgentRunnerLockMode::Exclusive => 0, + ExternalAgentRunnerLockMode::Shared => { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } + }; match OpenOptions::new() .create(true) .read(true) .write(true) - .share_mode(0) + .share_mode(share_mode) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) { @@ -875,16 +927,30 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - // share_mode(0) gives this process an exclusive handle. At this point the fixed - // lock path is known to be a stale, single-link, non-reparse regular file inside - // the current TokenUser's private AppData. Repairing its owner is therefore safe - // and is required when Windows creates it with TokenOwner=Administrators. + // The fixed lock path is known to be a single-link, non-reparse regular file + // inside the current TokenUser's private AppData. Repairing its owner is + // therefore safe and is required when Windows creates it with + // TokenOwner=Administrators. crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; validate_windows_regular_file_handle(&file, label)?; crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), + Err(error) + if mode == ExternalAgentRunnerLockMode::Exclusive + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Ok(None) + } + Err(error) + if mode == ExternalAgentRunnerLockMode::Shared + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )) + } Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() @@ -892,12 +958,29 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn open_external_agent_runner_lock_file( + path: &Path, + label: &str, + _mode: ExternalAgentRunnerLockMode, +) -> Result, String> { + Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) +} + #[cfg(not(any(unix, windows)))] pub(super) fn try_open_external_agent_runner_lock( path: &Path, label: &str, ) -> Result, String> { - Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) } pub(super) fn acquire_external_agent_runner_instance_lock( @@ -929,40 +1012,90 @@ pub(super) fn acquire_external_agent_runner_instance_lock( Ok(ExternalAgentRunnerInstanceLock { _file: file }) } -pub(crate) fn acquire_external_agent_runner_gui_owner_lock( +/// 取得本窗口在该 AppData 下的界面参与锁。 +/// +/// 参与锁以共享句柄打开:同一 AppData 可以同时持有任意数量的界面窗口。 +/// Runner 侧用同文件的独占探测判断“是否仍有界面进程存活”,探测窗口很短, +/// 所以这里遇到瞬时冲突时按 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL` +/// 重试,直到 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT` 截止。 +pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, -) -> Result { - let path = external_agent_runner_gui_owner_lock_path(config_dir); - let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")? - else { - return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string()); - }; - let owner_epoch = uuid::Uuid::new_v4().to_string(); - let acquired_at = unix_millis(); +) -> Result { + let path = external_agent_runner_gui_participant_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; + let mut last_error = "AGC 界面参与锁未知失败".to_string(); + loop { + match open_external_agent_runner_lock_file( + &path, + "AGC 界面参与锁", + ExternalAgentRunnerLockMode::Shared, + ) { + Ok(Some(mut file)) => { + write_external_agent_runner_gui_participant_diagnostic(&mut file, &path)?; + return Ok(ExternalAgentRunnerGuiParticipantLock { _file: file }); + } + Ok(None) => { + last_error = format!("AGC 界面参与锁无法以共享方式取得:{}", path.display()); + } + Err(error) => last_error = error, + } + if Instant::now() >= deadline { + return Err(format!("取得 AGC 界面参与锁失败:{last_error}")); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL); + } +} + +/// 参与锁诊断内容只由首个窗口写入,后续窗口不覆写,避免并发写坏 JSON。 +fn write_external_agent_runner_gui_participant_diagnostic( + file: &mut File, + path: &Path, +) -> Result<(), String> { + let existing_len = file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + if existing_len > 0 { + return Ok(()); + } let diagnostic = serde_json::to_vec(&json!({ "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, "pid": std::process::id(), - "ownerEpoch": owner_epoch, - "acquiredAt": acquired_at, + "instanceId": uuid::Uuid::new_v4().to_string(), + "acquiredAt": unix_millis(), })) - .map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?; + .map_err(|error| format!("生成 AGC 界面参与锁信息失败:{error}"))?; file.set_len(0) .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) .and_then(|_| file.write_all(&diagnostic)) .and_then(|_| file.sync_data()) - .map_err(|error| { - format!( - "写入 Agent Runner GUI owner 锁信息失败:{}: {error}", - path.display() - ) - })?; - write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?; - Ok(ExternalAgentRunnerGuiOwnerLock { - _file: file, + .map_err(|error| format!("写入 AGC 界面参与锁信息失败:{}: {error}", path.display())) +} + +/// 发布新的 durable claim:新 epoch + 本次会话 revision。 +/// +/// 发布是“谁改动登录态谁成为新 epoch 权威”的实现;并发发布以最后一次 +/// 成功写入为准,落败窗口按最新 claim 重试。 +pub(crate) fn publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + let owner_epoch = uuid::Uuid::new_v4().to_string(); + write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, session_revision)?; + Ok(ExternalAgentRunnerGuiOwnerClaim { owner_epoch, + session_revision, }) } +/// 采纳现有 durable claim;只有 claim 缺失或不可读时才发布新 claim。 +pub(crate) fn adopt_or_publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + match read_external_agent_runner_gui_owner_claim(config_dir) { + Ok(claim) => Ok(claim), + Err(_) => publish_external_agent_runner_gui_owner_claim(config_dir, session_revision), + } +} + pub(super) fn write_external_agent_runner_gui_owner_claim_atomic( config_dir: &Path, owner_epoch: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 4f0e3d555..0728e9050 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -13,8 +13,8 @@ pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str = - "agent-runner.gui-owner.lock"; +pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str = + "agent-runner.gui-participant.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str = "agent-runner.gui-owner.claim.json"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index cde54a691..998ca0e9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -156,7 +156,7 @@ fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) - if !state.gui_owner_attached.load(Ordering::Acquire) { return false; } - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let _ = validate_external_agent_runner_gui_owner_claim_current(state); false @@ -224,7 +224,7 @@ pub(crate) fn run_external_agent_runner_server( )?; let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( gui_owner_required, - external_agent_runner_gui_owner_is_locked(&external_agent_runner_gui_owner_lock_path( + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( &config_dir, ))?, )?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 3070039b6..7dda8ab43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) draining: AtomicBool, pub(super) active_connections: AtomicUsize, pub(super) known_roots: Mutex>, - pub(super) gui_owner_lock_path: PathBuf, + pub(super) gui_participant_lock_path: PathBuf, pub(super) project_execution_owners: Mutex>, project_execution_owner_recovery_changed: Condvar, @@ -80,10 +80,10 @@ impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { - let gui_owner_lock_path = endpoint_path + let gui_participant_lock_path = endpoint_path .parent() - .map(external_agent_runner_gui_owner_lock_path) - .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)); + .map(external_agent_runner_gui_participant_lock_path) + .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME)); Self { endpoint_path, endpoint: Mutex::new(endpoint), @@ -94,7 +94,7 @@ impl ExternalAgentRunnerServerState { draining: AtomicBool::new(false), active_connections: AtomicUsize::new(0), known_roots: Mutex::new(BTreeSet::new()), - gui_owner_lock_path, + gui_participant_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), @@ -231,16 +231,10 @@ pub(super) struct ExternalAgentRunnerInstanceLock { pub(super) _file: File, } +/// 界面进程持有的参与锁。共享句柄,同一 AppData 可同时存在多个窗口。 #[derive(Debug)] -pub(crate) struct ExternalAgentRunnerGuiOwnerLock { +pub(crate) struct ExternalAgentRunnerGuiParticipantLock { pub(super) _file: File, - pub(super) owner_epoch: String, -} - -impl ExternalAgentRunnerGuiOwnerLock { - pub(crate) fn owner_epoch(&self) -> &str { - &self.owner_epoch - } } pub(super) struct ExternalAgentRunnerProjectOwnerStorage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 20a6dfc0d..8e9bcb08b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -44,6 +44,23 @@ fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf { .expect("prepare private runner AppData") } +/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。 +struct TestGuiParticipant { + _lock: ExternalAgentRunnerGuiParticipantLock, + owner_epoch: String, +} + +fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir) + .expect("acquire GUI participant lock"); + let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + .expect("publish GUI owner claim"); + TestGuiParticipant { + _lock: lock, + owner_epoch: claim.owner_epoch, + } +} + fn acquire_project_owner_after_release( root: &Path, boot_id: &str, @@ -574,8 +591,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; - register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params) - .expect("register GUI owner attachment"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + params, + ) + .expect("register GUI owner attachment"); let calls = std::cell::RefCell::new(Vec::new()); let endpoint_a = test_endpoint( @@ -657,6 +679,7 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_330), event_sink_token: Some("f".repeat(64)), @@ -725,6 +748,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_331), event_sink_token: Some("d".repeat(64)), @@ -776,6 +800,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { platform_user_id: Some("user-a".to_string()), platform_access_token: Some("token-a".to_string()), @@ -827,8 +852,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { fn gui_owner_platform_session_payload_clears_runner_session() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire platform-session clear owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-clear-token", "platform-clear-boot", 31_333), @@ -841,7 +865,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), ..ExternalAgentRunnerRequestParams::default() @@ -855,8 +879,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire partial-session owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-partial-token", "platform-partial-boot", 31_334), @@ -870,7 +893,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let error = apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), @@ -896,9 +919,8 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old "runner-token-seed", "https://dev.genarrative.world", ); - let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire old GUI owner epoch"); - let owner_a_epoch = owner_a.owner_epoch().to_string(); + let owner_a = acquire_test_gui_participant(&config_dir, 0); + let owner_a_epoch = owner_a.owner_epoch.clone(); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { @@ -914,12 +936,11 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old .expect("old GUI installs high-generation owner A"); drop(owner_a); - let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire new GUI owner epoch"); + let owner_b = acquire_test_gui_participant(&config_dir, 0); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner_b.owner_epoch().to_string()), + gui_owner_epoch: Some(owner_b.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), @@ -963,8 +984,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "platform-claim-gate-boot", 31_337), ); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim gate owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let _session = crate::install_test_platform_session( "runner-owner-seed", "runner-token-seed", @@ -973,7 +993,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), @@ -985,7 +1005,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ .expect("attach owner A claim"); state.gui_owner_attached.store(true, Ordering::Release); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance durable claim before reattach"); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state) @@ -996,7 +1016,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), @@ -1041,14 +1061,14 @@ fn failed_platform_session_sync_fences_runner_before_returning_error() { fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim-write failure owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), @@ -1069,7 +1089,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, - |_, _, _| Err("injected durable claim write failure".to_string()), + |_, _| Err("injected durable claim write failure".to_string()), ) }, || { @@ -1118,6 +1138,7 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams::default(), ) .expect("register GUI owner attachment"); @@ -1166,6 +1187,7 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_322), event_sink_token: Some("c".repeat(64)), @@ -1215,6 +1237,7 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_323), event_sink_token: Some("d".repeat(64)), @@ -1267,6 +1290,7 @@ fn gui_owner_registration_does_not_cross_config_dirs() { register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_324), event_sink_token: Some(event_sink_token.clone()), @@ -1316,18 +1340,113 @@ fn gui_owner_registration_does_not_cross_config_dirs() { } #[test] -fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { +fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let first = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("first GUI owns AppData"); - let error = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect_err("second GUI must not share the same Runner owner"); - assert!(error.contains("其他进程运行")); + let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir); + assert!(!external_agent_runner_lock_is_held(&participant_lock_path) + .expect("probe without any window")); + let first = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("first window participates"); + assert!(external_agent_runner_lock_is_held(&participant_lock_path) + .expect("first window keeps the runner alive")); + let second = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("second window shares the same AppData"); + + drop(second); + assert!( + external_agent_runner_lock_is_held(&participant_lock_path) + .expect("remaining window keeps the runner alive"), + "runner must survive while any window is still open" + ); drop(first); - acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("GUI owner lock is recoverable after the first frontend exits"); + assert!( + !external_agent_runner_lock_is_held(&participant_lock_path) + .expect("last window releases the participant lock"), + "runner may stop once every window has exited" + ); +} + +#[test] +fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let published = + publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim"); + assert_eq!(published.session_revision, 3); + + let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9) + .expect("adopt existing claim"); + assert_eq!(adopted.owner_epoch, published.owner_epoch); + assert_eq!( + adopted.session_revision, 3, + "采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch" + ); + + let rotated = + publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim"); + assert_ne!(rotated.owner_epoch, published.owner_epoch); + assert_eq!(rotated.session_revision, 9); + assert_eq!( + read_external_agent_runner_gui_owner_claim(&config_dir) + .expect("read durable claim") + .session_revision, + 9 + ); +} + +#[test] +fn second_window_attach_with_same_claim_keeps_runner_platform_session() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "multi-window-claim-token-multi-window-claim-token", + "multi-window-claim-boot", + 31_338, + ), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + let owner = acquire_test_gui_participant(&config_dir, 0); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + platform_access_token: Some("runner-token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(7), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("first window installs its session"); + assert_eq!( + crate::current_platform_session().map(|session| (session.user_id, session.generation)), + Some(("runner-owner-a".to_string(), 7)) + ); + + // 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。 + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("second window attaches with the same claim"); + assert_eq!( + crate::current_platform_session().map(|session| (session.user_id, session.generation)), + Some(("runner-owner-a".to_string(), 7)), + "同一 claim 的第二个窗口不得清空平台登录态" + ); } #[test] @@ -1341,8 +1460,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "gui-owner-monitor-boot", 31319), ); - let owner = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("acquire GUI owner lock"); + let owner = acquire_test_gui_participant(&config_dir, 0); let attached = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, @@ -1352,7 +1470,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1372,7 +1490,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance owner claim revision"); let replacement = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1383,7 +1501,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_319), event_sink_token: Some("c".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, @@ -1392,11 +1510,18 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u ); assert!(replacement.ok); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }) + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], + "第二个窗口 attach 必须让两个接收端同时保留" ); let stale_replay = handle_external_agent_runner_request( @@ -1408,7 +1533,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1421,11 +1546,17 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u Some("platform-session-invalid") ); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }), + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], "旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f12416e7d..a91336e16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -202,9 +202,13 @@ fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { GameCreatorGuiRunnerShutdownOutcome::NotRequested ); assert_eq!( - resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())), + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(true)), GameCreatorGuiRunnerShutdownOutcome::Requested ); + assert_eq!( + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(false)), + GameCreatorGuiRunnerShutdownOutcome::Retained + ); assert_eq!( resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || { Err("private shutdown diagnostic".to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs index c49ef18c1..d9cd7e56b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; +const GUI_PARTICIPANT_LOCK_FILE_NAME: &str = "agent-runner.gui-participant.lock"; struct TestDirectory(PathBuf); @@ -50,8 +50,9 @@ fn open_locked_file(path: &Path) -> File { .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) .open(path) .expect("open isolated lock file"); + // 模拟一个界面窗口:参与锁以共享锁持有,多个窗口可以同时持有。 // SAFETY: file owns a live descriptor and flock does not retain pointers. - assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0); + assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH) }, 0); file } @@ -101,7 +102,7 @@ fn runner_binary() -> &'static str { #[test] fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { let directory = TestDirectory::new("owner-lost-before-check"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let script = "kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required"; let mut child = Command::new("/bin/sh") @@ -139,7 +140,7 @@ fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { #[test] fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() { let directory = TestDirectory::new("owner-lost-after-start"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let mut child = Command::new(runner_binary()) .arg("--agent-runner") .arg("--config-dir") diff --git a/docs/project-memory/plans/【实施计划】AGC同AppData多窗口共享Runner-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC同AppData多窗口共享Runner-2026-09-16.md new file mode 100644 index 000000000..29bdd0703 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC同AppData多窗口共享Runner-2026-09-16.md @@ -0,0 +1,41 @@ +--- +Version: 1 +Status: Active +Date: 2026-09-16 +Milestone: 【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md +--- + +# 【实施计划】AGC 同 AppData 多窗口共享 Agent Runner + +## 代码边界 + +| 顺序 | 文件 | 修改 | +| --- | --- | --- | +| 1 | `runner/endpoint.rs` | 参与锁改名与共享句柄打开;新增 claim 发布;锁路径白名单同步 | +| 2 | `runner/state.rs` | `ExternalAgentRunnerGuiOwnerLock` → 参与者锁;服务端状态字段改名 | +| 3 | `runner/dispatch.rs` | attach 只在 epoch 变化时强制替换登录态;同 claim 重复 attach 为幂等空操作;事件接收端改为注册表 | +| 4 | `runner/server.rs` | watchdog 与启动检查按“参与者存活”判定 | +| 5 | `runner/client.rs` | 启动采纳 claim、平台登录态变化发布新 claim、attach 失败按最新 claim 重试 | +| 6 | `agent/runtime_driver*` | 事件接收端从单槽改为按 token 去重的注册表并广播 | +| 7 | `main.rs` | 启动阶段取参与者锁并 attach,不再要求独占 owner | +| 8 | `runner/tests.rs` | 重写独占锁断言,补齐多窗口、广播、存活判定与失败关闭用例 | + +## 修改顺序 + +先改 runner 内部(endpoint / state / dispatch / server),再改 GUI 客户端(client / main),最后改事件注册表与测试。每步保持可编译。 + +## 验证命令 + +``` +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml +npm run check:encoding +git diff --check +``` + +真实二进制 smoke 在构建产物可用后执行:同一 AppData 启动两个 GUI,核对 `diagnostics/startup.log` 两次 `startup.setup.complete`、只有一个 `--agent-runner` 进程、关闭一个窗口后另一个仍可 attach。 + +## 风险与回滚 + +- 风险:共享参与锁后,Runner 可能在无窗口时仍存活。回滚点是 watchdog 的存活判定函数,恢复为独占 owner 语义即可。 +- 风险:claim 发布竞态。缓解为 bounded retry + 失败关闭,不做隐式合并。 +- 回滚:本里程碑只影响本机 GUI ↔ Runner 协议,回滚提交即可恢复单窗口语义;claim 与参与锁文件都是可重新生成的运行时产物。 diff --git a/docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md new file mode 100644 index 000000000..78f6ad7f2 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md @@ -0,0 +1,39 @@ +--- +Version: 1 +Status: Active +Date: 2026-09-16 +Parent Spec: docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +--- + +# 【里程碑】AGC 同 AppData 多窗口共享 Agent Runner + +## 目标 + +同一个 AppData 允许同时运行多个 AGC 界面进程;它们共享同一个 Agent Runner、同一份平台登录态权威和同一份项目事实,并且都继续收到 Runtime / manifest 失效事件。 + +## 边界 + +- 参与本次变化的只有 GUI 与 Agent Runner 之间的本机协议:窗口参与锁、owner claim 发布与采纳、attach 语义、事件接收端注册表、Runner 存活判定与启动诊断。 +- 不修改平台 HTTP 契约、SpacetimeDB schema、`/api/external/v1`、渲染层 generation 语义和项目级 `.agent/project.lock`。 +- 一个项目同一时刻仍然只允许一个写者;多窗口不能并行写同一项目。 + +## 行为合同 + +1. 窗口启动不再因为“同一 AppData 已有界面进程”而失败。参与锁是共享句柄锁:任何数量的界面进程都可以同时持有;Runner 通过“能否独占取得该锁”判断是否还有界面进程存活。 +2. 启动时窗口先采纳现有 owner claim;只有在 claim 缺失、不可读,或本窗口主动发布平台登录态变化时,才写入新的 claim(新 epoch + 本窗口 generation)。 +3. 同一 claim 的重复 attach 不得清空 Runner 平台登录态;只有 epoch 变化或明确的登出参数才允许替换 / 清空登录态。 +4. Runner 必须把 manifest 失效与 runtime update relay 广播给全部已登记事件接收端(按 token 去重、失败即淘汰),不得因为第二个窗口 attach 而让第一个窗口失去事件。 +5. Runner 生命周期跟随“是否仍有界面进程存活”,不跟随某一个窗口;全部窗口退出后必须在有界时间内关停并清理 endpoint。 +6. 平台登录态仍保持单调 generation 与 claim 一致性校验:claim 不匹配时继续失败关闭并隔离登录态,不做隐式兼容。 + +## 验收标准 + +- 定向 Rust 测试覆盖:第二个参与者可同时持有参与锁;参与者全部退出后存活判定为假;同 claim 重复 attach 不改写登录态;epoch 变化才强制替换;两个事件接收端都能收到同一事件;claim 采纳与发布路径的失败关闭。 +- 真实二进制 smoke:同一 AppData 启动两个 GUI,两个进程都完成 `startup.setup.complete`,Runner 只存在一个实例,关闭其中一个后另一个仍可继续使用 Runner。 +- 边界验证:项目级写锁仍然拒绝两个窗口同时写同一项目;密钥、token、绝对路径不出现在新增日志与错误文案中。 + +## 依赖 + +- `apps/ai-game-creator-shell/src-tauri/src/runner/`(endpoint / state / dispatch / server / client) +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/`(事件接收端注册表) +- `apps/ai-game-creator-shell/src-tauri/src/main.rs`(启动阶段) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..360df2121 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,18 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 + +## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner + +- 背景:双击或再次启动 AGC 客户端时报「应用启动失败」,启动日志为 `startup.runner.owner-lock.failed details=AI 游戏创作界面已由同一 AppData 目录中的其他进程运行`。原设计(2026-07-27 / 2026-08-23)要求同一 AppData 只有一个 GUI owner,第二个界面进程在 setup 阶段就失败退出。 +- 决策(锁语义):`agent-runner.gui-owner.lock` 改为**界面参与锁** `agent-runner.gui-participant.lock`,以共享句柄打开,同一 AppData 的任意数量窗口可同时持有;Runner 的启动检查、attach 门禁与 watchdog 只用“能否独占取得该文件”判断是否仍有窗口存活。全部窗口退出后才关停 Runner 并清理 endpoint。 +- 决策(claim 采纳与发布):窗口启动先**采纳**durable claim(同一 epoch/revision),只有 claim 缺失或不可读才发布新 claim;登录、refresh、退出或换号才发布新 claim(新 epoch + 本窗口 revision),成为新的登录态权威。同一 claim 的重复 attach 是幂等空操作,不再清空 Runner 登录态;只有 epoch 变化或携带明确登出参数才允许替换 / 清空。并发发布以最后一次成功写入的 claim 为准,落败窗口按最新 claim 有界重试。 +- 决策(事件与退出):manifest 失效与 Runtime update relay 的接收端从单槽改为按 `event_sink_token` 去重的注册表并广播,发送失败只淘汰该接收端;GUI 退出先释放本窗口参与锁,仍有其它窗口时保留 Runner(`agent.runner.gui_exit.retained_for_other_windows`),最后一个窗口才请求关闭。Runner 启动失败时先按最新 endpoint 复用一次,避免两个窗口同时冷启动时的实例锁竞争被误报成启动失败。 +- 边界:本机 GUI ↔ Runner 协议方法与参数不变,不引入多 Runner、不做跨 AppData 会话共享;项目级 `.agent/project.lock` 不变,多窗口仍不能并行写同一项目;平台登录态 generation 单调与 claim 失配失败关闭语义保持不变;混用新旧版本二进制访问同一 AppData 不属于支持场景。 +- 验证:定向 Rust `runner::tests::gui_owner_*` 11 条与新增的参与锁多窗口 / 存活判定 / claim 采纳与轮换 / 同 claim 第二个窗口不清空登录态用例全部通过;真实 debug 二进制 Windows smoke 证明同一 AppData 两个 GUI 都完成 `startup.setup.complete`、只存在一个 `--agent-runner` 进程、关闭一个窗口后另一个窗口与 Runner 继续存活、最后一个窗口退出后 Runner 退出并删除 endpoint;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。 +- 未验证 / 已知环境问题:真实安装包双开需要重新构建发布后才能验证;`durable_provider_handoff_prevents_shutdown_even_when_corrupt`、`durable_provider_retry_prevents_shutdown_and_reopens_writes`、`runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor` 三条用例在本机改动前的基线上即失败(Windows 安全对象 owner 校验与 Provider 请求重复),与本决策无关。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(2026-09-16 节)、`docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md`。 + ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e8d132d9f..3b2461b1a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 2026-09-16 从 Codex 里启动 AGC 客户端会看到被重定向的 `%APPDATA%` + +- **现象**:在 Codex 会话里用 `Start-Process` 启动 `genarrative-ai-game-creator-shell.exe` 做排障时,子进程写 `C:\Users\\AppData\Roaming\world.genarrative.ai-game-creator\...` 的内容会落到 `C:\Users\\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Roaming\...`;同一个 `Test-Path` / `Get-ChildItem` 命中的是重定向视图,只有 `\\?\C:\Users\...` 形式能区分真实路径。 +- **影响**:用 agent 拉起的客户端复现「多开 / 登录态冲突 / 锁文件被占用」类问题时,可能与用户双击开始菜单快捷方式的真实进程不是同一份 AppData,从而得出「两个实例没有互相冲突」或「日志里没有那条失败」的错误结论。 +- **处理**:把用户双击快捷方式(或从 Codex 之外启动)的进程作为唯一用户侧证据;核对待查文件时同时比对真实 `AppData\Roaming` 路径与 `Packages\...\LocalCache\Roaming` 路径;排障结论里写明客户端是「谁启动的」。 + ## Windows 已登记生图资产未刷新 Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 06089fbd6..61c651581 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -548,6 +548,7 @@ Agent Runtime 负责: - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-08-23 Runner 协议 v7 GUI owner 会话权威补充:GUI 取得 owner OS 锁时产生随机 `owner epoch`,并在私有 AppData 持久化只含 `owner epoch + session revision` 的 claim;每次登录、refresh、退出或换号都必须先单调推进 durable session revision,再同步 Runner。`runner.attach_gui_owner` 是 Runner 接受平台会话快照的唯一授权入口;只有 attach 携带的 epoch/revision 与 durable claim 完全一致才可安装或清除会话,新 GUI epoch 可替换旧进程留下的高 `authGeneration`,不用可在新 WebView 重置的 generation 猜测进程所有权。Runner 在 claim 缺失、不可读或与当前 attach 身份失配时立即清空进程内平台会话,并阻断除重新 attach 及必要管理请求以外的 Runtime 工作;旧 `platform.session.install/clear` 协议不再是授权入口。GUI 会话同步未得到完整 attach 确认时本地变更必须失败,并隔离或停止旧 Runner;即使进程终止失败,claim 失配门禁也不允许旧账号继续发起 Runtime 请求。claim 不保存 Access Token,Token 只随当次受保护的 attach IPC 进入 Runner 内存。 +- 2026-09-16 多窗口更正:同一 AppData 不再只允许一个 GUI 界面进程。原 owner OS 锁改为可被多个界面进程同时持有的参与者锁(`agent-runner.gui-participant.lock`),Runner 以“能否独占取得该锁”判断是否仍有界面进程存活,watchdog 与 attach 门禁都改用该判定。新增窗口默认只**采纳** durable claim(读同一 epoch/revision 并 attach),只有登录、refresh、退出或换号才发布新 claim(新 epoch + 本窗口 revision),因此同 claim 的重复 attach 不再清空平台会话,epoch 变化才允许强制替换。事件接收端从单槽改为按 token 去重的注册表并广播,保证第二个窗口 attach 后第一个窗口仍收到 manifest 失效与 Runtime update relay。claim 一致性与失败关闭语义不变。详见同文档「2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner」。 - 2026-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 @@ -1447,3 +1448,42 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 运行中的正文和工具按原有唯一回合流实时显示;完成后,除最终回复和失败提示外,中间文本与所有工具调用统一放入默认收起的“执行过程”,允许手动展开,刷新或重新进入仍默认收起。 - 最终回复沿用 Runtime 的最后一个 assistant item 合同,不按文本长度或相似度判断。失败回合不把最后一句过程输出伪装成最终回复。无流历史按同一用户消息边界划分,只保留最后一条 assistant 回复在外;用户消息与失败提示始终保留。 - 验收覆盖已完成回合重进、真实活动回合恢复、跨项目迟到快照、运行到完成自动收起、历史无流、失败、发送时间刷新和旧记录时间缺失。不改变实际工具执行、鉴权、数据库或用户项目内容。 + +## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner + +### 目标与非目标 + +- 目标:同一个 AppData 可以同时运行多个 AGC 界面进程,它们共享同一个 Agent Runner、同一份平台登录态权威和同一份项目事实,并且都继续收到 manifest 失效与 Runtime update relay。 +- 非目标:不引入多 Runner、不做跨 AppData 的会话共享、不改变渲染层 generation 语义、不修改平台 HTTP 契约、SpacetimeDB schema 与 `/api/external/v1`。 + +### 参与入口、状态与跨模块边界 + +- 界面进程持有的 OS 锁改为**参与者锁** `agent-runner.gui-participant.lock`:它以共享句柄打开,任意数量的界面进程可同时持有;Runner、watchdog 与 attach 门禁只做“能否独占取得该锁”的探测,独占成功即表示已无界面进程存活。 +- durable claim `agent-runner.gui-owner.claim.json` 仍是唯一的 owner 授权记录,内容仍为 `ownerEpoch + sessionRevision`;claim 不保存 Access Token。 +- 项目级 `.agent/project.lock` 不变:多窗口共享 Runner 不等于共享项目写权,同一项目同一时刻仍只有一个写者。 + +### 正常、失败、重试与幂等行为 + +- 采纳:窗口启动时先读取 durable claim 并以同一 `epoch/revision` attach;claim 缺失或不可读时才发布新 claim。采纳路径不写 claim。 +- 发布:登录、refresh、退出或换号时,窗口写入新 claim(新 epoch + 本窗口 revision)再 attach;同一次发布在多个窗口并发发生时以最后一次成功写入的 claim 为准,落败窗口按最新 claim 重试,重试仍有界失败时向用户暴露错误,不做隐式合并。 +- 幂等:同一 claim 的重复 attach 是空操作,不得清空或替换 Runner 平台登录态;只有 epoch 变化或携带明确登出参数的 attach 才允许替换 / 清空。 +- 失败关闭:attach 携带的 epoch/revision 与 durable claim 不一致、claim 不可读、或 claim 在 attach 提交期间变化时,Runner 继续清空进程内平台会话并阻断除重新 attach 与必要管理请求以外的 Runtime 工作。 +- 事件:manifest 失效与 Runtime update relay 广播到全部已登记接收端(按 `event_sink_token` 去重,发送失败即淘汰该接收端),单个窗口退出或接收端失效不得影响其它窗口。 +- 生命周期:Runner 跟随“是否仍有界面进程存活”,不跟随某一个窗口;全部窗口退出后必须在有界时间内关停并清理 endpoint、释放实例锁。 + +### 契约与兼容 + +- 本机 GUI ↔ Runner 协议方法与参数不变;变化只在参与锁语义、claim 采纳/发布时机与事件接收端注册表。 +- 已退役的“同一 AppData 只允许一个 GUI owner”行为不再保留兼容分支;`startup.runner.owner-lock.failed` 诊断分类改名为参与者锁失败,仅在真正无法建立参与锁时出现。 +- 混用新旧版本二进制访问同一 AppData 不属于支持场景:旧版本仍以独占方式持有旧锁文件,可能让新版本判定为“仍有界面进程存活”。 + +### 验收标准与证据来源 + +- 定向 Rust 测试:参与者锁可多进程同时持有、全部释放后存活判定为假;同 claim 重复 attach 不改写登录态;epoch 变化才强制替换;两个接收端都收到同一事件;claim 采纳与发布的失败关闭路径。 +- 运行时 smoke:同一 AppData 启动两个真实 GUI,两个进程都完成 `startup.setup.complete`,`--agent-runner` 进程只有一个,关闭其中一个后另一个仍可继续使用 Runner。 +- 边界:项目级写锁继续拒绝两个窗口同时写同一项目;新增日志与错误文案不含 Token、Access Token、API Key 与绝对路径。 + +### 未决问题 + +- 两个窗口同时对同一项目发起 Runtime 写请求时,用户体验仍由项目级写锁串行决定;本次不引入跨窗口排队提示。 +- 平台会话在窗口间传播依赖共享 localStorage 与 Runner 权威;渲染层不做跨窗口事件推送,另一个窗口在下一次会话校验或刷新时收敛。