同步master最新变更
合入当前主线的多窗口与配置更新 保留AGC画布交互和JSON识别修复 保留双方新增的项目排障记录并解决文档冲突
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.47"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2018,7 +2017,13 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let credential = if llm.custom_enabled {
|
||||
crate::config::validate_custom_llm_connection(llm)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
}
|
||||
} else if game_creator_official_llm_route_locked() {
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2186,7 +2191,8 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -4743,6 +4749,8 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5528,6 +5536,59 @@ mod tests {
|
||||
assert_ne!(command_token, provider_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
||||
let mut llm = test_llm();
|
||||
llm.custom_enabled = true;
|
||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
||||
llm.model = "vendor/model.v1:latest".into();
|
||||
llm.visible_models = vec![llm.model.clone()];
|
||||
let credential = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "custom-fixture".into(),
|
||||
};
|
||||
let (base, key) = credential
|
||||
.direct_provider_route(&llm)
|
||||
.expect("custom route");
|
||||
assert_eq!(base, llm.base_url);
|
||||
assert_eq!(key, llm.api_key);
|
||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
||||
for mode in [
|
||||
CodexAppServerWorkspaceMode::DirectProject,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
] {
|
||||
let mut command = tokio::process::Command::new("fixture");
|
||||
configure_game_creator_codex_app_server_command_for_mode(
|
||||
&mut command,
|
||||
&llm,
|
||||
mode,
|
||||
Some(&proxy),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let arguments = command
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let params = codex_app_server_thread_start_params(
|
||||
&llm.model,
|
||||
std::path::Path::new("fixture-workspace"),
|
||||
mode,
|
||||
String::new(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
||||
assert!(!arguments.contains(&llm.api_key));
|
||||
assert!(!arguments.contains("/api/llm"));
|
||||
for (_, value) in command.as_std().get_envs() {
|
||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -5329,6 +5329,8 @@ mod tests {
|
||||
|
||||
fn direct_test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
|
||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "finalization-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "finalization-tool-plan-model".to_string(),
|
||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-provider-model".to_string(),
|
||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-tool-plan-provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-tool-plan-model".to_string(),
|
||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||
let root = project.path();
|
||||
let old_llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "old-generic-retry-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "old-generic-retry-model".to_string(),
|
||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||
let root = project.path();
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-capacity-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-capacity-model".to_string(),
|
||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
LlmMessage::user("修复格式"),
|
||||
]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "durable-control-tool-plan-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "durable-control-tool-plan-model".to_string(),
|
||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "tool-plan-cleanup-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "tool-plan-cleanup-model".to_string(),
|
||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "terminal-handoff-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "terminal-handoff-model".to_string(),
|
||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
let root = project.path();
|
||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||
let llm = GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "provider-key".to_string(),
|
||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||
model: "provider-model".to_string(),
|
||||
|
||||
@@ -2,8 +2,9 @@ use super::*;
|
||||
|
||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||
OnceLock::new();
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = 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,
|
||||
|
||||
@@ -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<GameCreatorManifestInvalidationEventSink>> {
|
||||
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<GameCreatorManifestInvalidationEventSink>> {
|
||||
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<GameCreatorManifestInvalidationEventSink> {
|
||||
lock_game_creator_manifest_invalidation_event_sink().clone()
|
||||
lock_game_creator_manifest_invalidation_event_sinks()
|
||||
.first()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
|
||||
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}"))?;
|
||||
|
||||
@@ -1991,6 +1991,8 @@ pub(crate) fn write_game_creator_app_config(
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
let (current, overlays) = load_game_creator_app_config_for_write()?;
|
||||
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
|
||||
config.llm.custom_enabled = current.llm.custom_enabled;
|
||||
config.selected_model_id = current.selected_model_id;
|
||||
config.selected_model_is_default = current.selected_model_is_default;
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
@@ -2027,7 +2029,12 @@ pub(crate) fn select_game_creator_model(
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
if model_id.is_empty()
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
if config.llm.custom_enabled {
|
||||
if !config.llm.visible_models.contains(&model_id) {
|
||||
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
|
||||
}
|
||||
} else if model_id.is_empty()
|
||||
|| model_id.len() > 64
|
||||
|| !model_id
|
||||
.bytes()
|
||||
@@ -2035,12 +2042,21 @@ pub(crate) fn select_game_creator_model(
|
||||
{
|
||||
return Err("模型标识无效".into());
|
||||
}
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
config.selected_model_id = model_id;
|
||||
config.selected_model_is_default = is_default;
|
||||
persist_game_creator_app_config(config, overlays, true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn discover_game_creator_llm_models(
|
||||
llm: GameCreatorLlmConfig,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !load_game_creator_app_config()?.llm.custom_enabled {
|
||||
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
|
||||
}
|
||||
fetch_custom_llm_models(&llm).await
|
||||
}
|
||||
|
||||
fn persist_game_creator_app_config(
|
||||
config: GameCreatorAppConfig,
|
||||
overlays: Vec<(PathBuf, serde_json::Value)>,
|
||||
@@ -2056,8 +2072,8 @@ fn persist_game_creator_app_config(
|
||||
let previous = overlay.clone();
|
||||
if let Some(fields) = overlay.as_object_mut() {
|
||||
for (key, value) in fields.iter_mut() {
|
||||
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
== model_only
|
||||
if !model_only
|
||||
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
|
||||
{
|
||||
if let Some(saved_value) = saved.get(key) {
|
||||
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
custom_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
visible_models: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
api_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorLlmConfig {
|
||||
#[serde(default)]
|
||||
custom_enabled: bool,
|
||||
#[serde(default)]
|
||||
visible_models: Vec<String>,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
model: String,
|
||||
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
|
||||
impl Default for GameCreatorLlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: String::new(),
|
||||
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
|
||||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||||
@@ -2209,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||
NotRequested,
|
||||
Requested,
|
||||
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||
Retained,
|
||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||
}
|
||||
|
||||
@@ -2246,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
|
||||
@@ -2262,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
||||
shutdown: F,
|
||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||
where
|
||||
F: FnOnce() -> Result<(), String>,
|
||||
F: FnOnce() -> Result<bool, String>,
|
||||
{
|
||||
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),
|
||||
),
|
||||
@@ -2288,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())
|
||||
}
|
||||
@@ -2601,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!(
|
||||
@@ -2721,6 +2739,7 @@ fn main() {
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
select_game_creator_model,
|
||||
discover_game_creator_llm_models,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
create_ui_design_resource,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState {
|
||||
|
||||
struct ExternalAgentRunnerGuiOwnerRegistration {
|
||||
generation: u64,
|
||||
claim_mode: ExternalAgentRunnerGuiOwnerClaimMode,
|
||||
config_dir: PathBuf,
|
||||
params: ExternalAgentRunnerRequestParams,
|
||||
attached_boot_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
> = OnceLock::new();
|
||||
|
||||
static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock<
|
||||
Mutex<Option<ExternalAgentRunnerGuiParticipantLock>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn external_agent_runner_gui_owner_attachment_state(
|
||||
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
||||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
|
||||
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
|
||||
}
|
||||
|
||||
fn external_agent_runner_gui_participant_lock(
|
||||
) -> &'static Mutex<Option<ExternalAgentRunnerGuiParticipantLock>> {
|
||||
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
) -> 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<ExternalAgentRunnerGuiOwnerClaim, String> {
|
||||
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<F>(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
config_dir: &Path,
|
||||
@@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F
|
||||
attach: F,
|
||||
) -> 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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
session: Option<(&str, &str, &str)>,
|
||||
generation: u64,
|
||||
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
|
||||
publish_claim: impl FnOnce(&Path, u64) -> Result<ExternalAgentRunnerGuiOwnerClaim, String>,
|
||||
) -> 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<bool, S
|
||||
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
|
||||
}
|
||||
|
||||
/// 窗口退出的收尾:先释放本窗口参与锁,再决定 Runner 是否需要关闭。
|
||||
///
|
||||
/// 返回 `Ok(false)` 表示仍检测到其它窗口持有参与锁,Runner 必须保留给它们;
|
||||
/// 返回 `Ok(true)` 表示本窗口是最后一个界面进程,Runner 已请求关闭。
|
||||
pub(crate) fn shutdown_external_agent_runner_for_gui_exit() -> Result<bool, String> {
|
||||
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<ExternalAgentRunnerEndpoint, String> {
|
||||
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<Option<ExternalAgentRunnerEndpoint>, 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<Path>) -> Result<(), String> {
|
||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<PathBuf>> {
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Option<File>, 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<Option<File>, 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<Option<File>, 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<Option<File>, 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<Option<File>, 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<Option<File>, 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<ExternalAgentRunnerGuiOwnerLock, String> {
|
||||
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<ExternalAgentRunnerGuiParticipantLock, String> {
|
||||
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<ExternalAgentRunnerGuiOwnerClaim, String> {
|
||||
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<ExternalAgentRunnerGuiOwnerClaim, String> {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
))?,
|
||||
)?;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState {
|
||||
pub(super) draining: AtomicBool,
|
||||
pub(super) active_connections: AtomicUsize,
|
||||
pub(super) known_roots: Mutex<BTreeSet<PathBuf>>,
|
||||
pub(super) gui_owner_lock_path: PathBuf,
|
||||
pub(super) gui_participant_lock_path: PathBuf,
|
||||
pub(super) project_execution_owners:
|
||||
Mutex<BTreeMap<PathBuf, ExternalAgentRunnerProjectExecutionOwnerEntry>>,
|
||||
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 {
|
||||
|
||||
@@ -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 不能覆盖当前事件接收端"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() {
|
||||
// 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐
|
||||
// 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。
|
||||
let mut config: GameCreatorAppConfigFile = serde_json::from_str(
|
||||
r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ensure_game_creator_custom_llm_file_fields(&mut config));
|
||||
assert!(scrub_locked_game_creator_config_file(&mut config));
|
||||
let migrated: serde_json::Value = serde_json::to_value(&config).unwrap();
|
||||
assert_eq!(migrated["llm"]["customEnabled"], false);
|
||||
assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([]));
|
||||
assert_eq!(migrated["llm"]["apiKey"], "");
|
||||
assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL);
|
||||
assert_eq!(migrated["llm"]["model"], "quality");
|
||||
assert_eq!(
|
||||
migrated["llm"]["apiKind"],
|
||||
DEFAULT_GAME_CREATOR_LLM_API_KIND
|
||||
);
|
||||
assert_eq!(migrated["llm"]["reasoningEffort"], "max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||||
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
||||
write_game_creator_config_atomically(
|
||||
&primary,
|
||||
&serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
|
||||
let mut config = read_game_creator_app_config().unwrap().config;
|
||||
assert_eq!(config.llm.model, "a/v1");
|
||||
let selected = select_game_creator_model("b:v2".into(), false).unwrap();
|
||||
assert_eq!(selected.config.llm.model, "b:v2");
|
||||
assert!(select_game_creator_model("not-listed".into(), false).is_err());
|
||||
config.llm.visible_models = vec!["b:v2".into()];
|
||||
config.llm.api_key = "changed-fixture-key".into();
|
||||
write_game_creator_app_config(config).unwrap();
|
||||
let reloaded = read_game_creator_app_config().unwrap().config;
|
||||
assert!(reloaded.llm.custom_enabled);
|
||||
assert_eq!(reloaded.llm.visible_models, ["b:v2"]);
|
||||
assert_eq!(reloaded.llm.model, "b:v2");
|
||||
assert_eq!(reloaded.llm.api_key, "changed-fixture-key");
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap();
|
||||
for key in [
|
||||
"customEnabled",
|
||||
"visibleModels",
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"model",
|
||||
"apiKind",
|
||||
"reasoningEffort",
|
||||
] {
|
||||
assert!(
|
||||
persisted["llm"].get(key).is_some(),
|
||||
"本地配置始终保留 {key},方便手写自定义连接:{persisted}"
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_file_overrides_defaults_without_env() {
|
||||
let root = unique_project_path();
|
||||
@@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() {
|
||||
.llm
|
||||
.as_ref()
|
||||
.expect("global llm remains as non-sensitive tuning");
|
||||
assert!(llm.api_key.is_none());
|
||||
assert!(llm.base_url.is_none());
|
||||
assert!(llm.model.is_none());
|
||||
assert!(llm.api_kind.is_none());
|
||||
// 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。
|
||||
assert_eq!(llm.api_key.as_deref(), Some(""));
|
||||
assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL));
|
||||
assert_eq!(
|
||||
llm.api_kind.as_deref(),
|
||||
Some(DEFAULT_GAME_CREATOR_LLM_API_KIND)
|
||||
);
|
||||
assert!(llm.model.is_some());
|
||||
let serialized = serde_json::to_string(&config).expect("serialize scrubbed config");
|
||||
assert!(!serialized.contains("legacy-global-key"));
|
||||
assert!(!serialized.contains("legacy-agent-key"));
|
||||
@@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
agent_llm.insert(
|
||||
" planner ".to_string(),
|
||||
GameCreatorLlmConfigFile {
|
||||
custom_enabled: None,
|
||||
visible_models: None,
|
||||
api_key: Some(" planner-key ".to_string()),
|
||||
base_url: Some(" https://planner.example.test/v1 ".to_string()),
|
||||
model: Some(" planner-model ".to_string()),
|
||||
@@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(),
|
||||
agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(),
|
||||
llm: GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: " unit-test-key ".to_string(),
|
||||
base_url: " https://runtime.example.test/v1 ".to_string(),
|
||||
model: " runtime-model ".to_string(),
|
||||
@@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() {
|
||||
fs::create_dir_all(&root).expect("config dir");
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
||||
fs::write(
|
||||
write_game_creator_config_atomically(
|
||||
&overlay_path,
|
||||
r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#,
|
||||
)
|
||||
@@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
|
||||
fs::create_dir_all(&root).expect("config dir");
|
||||
let _guard = use_test_runtime_config_dir(root.clone());
|
||||
let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
|
||||
fs::write(
|
||||
write_game_creator_config_atomically(
|
||||
&overlay_path,
|
||||
r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -5885,6 +5885,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
|
||||
agent_llm.insert(
|
||||
"planner".to_string(),
|
||||
GameCreatorLlmConfigFile {
|
||||
custom_enabled: None,
|
||||
visible_models: None,
|
||||
api_key: Some("planner-key".to_string()),
|
||||
base_url: Some(planner_base_url),
|
||||
model: Some("planner-model".to_string()),
|
||||
@@ -5903,6 +5905,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
|
||||
agent_llm.insert(
|
||||
"generator".to_string(),
|
||||
GameCreatorLlmConfigFile {
|
||||
custom_enabled: None,
|
||||
visible_models: None,
|
||||
api_key: Some("generator-key".to_string()),
|
||||
base_url: Some(generator_base_url),
|
||||
model: Some("generator-model".to_string()),
|
||||
@@ -5921,6 +5925,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
|
||||
agent_llm.insert(
|
||||
"art-asset-plan".to_string(),
|
||||
GameCreatorLlmConfigFile {
|
||||
custom_enabled: None,
|
||||
visible_models: None,
|
||||
api_key: Some("art-key".to_string()),
|
||||
base_url: Some(art_base_url),
|
||||
model: Some("art-model".to_string()),
|
||||
|
||||
@@ -1494,7 +1494,7 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion()
|
||||
assert!(prompt_input.contains("只删除项目内普通文件"));
|
||||
|
||||
let verification_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("verification plan request");
|
||||
assert!(verification_request.contains("file.delete"));
|
||||
assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "陶泥儿",
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.47",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user