Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0388fa0912 | |||
| 79b153e3fa | |||
| 362edcc49d | |||
| 1e434e0cb5 | |||
| cec971c438 | |||
| 70157673b6 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| 737a2266b9 |
@@ -7,6 +7,10 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
|
|||||||
|
|
||||||
interface MockIntersectionObserverController {
|
interface MockIntersectionObserverController {
|
||||||
enter: (target: Element) => void;
|
enter: (target: Element) => void;
|
||||||
|
enterAll: (targets: Element[]) => void;
|
||||||
isObserved: (target: Element) => boolean;
|
isObserved: (target: Element) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
enterAll(targets) {
|
||||||
|
act(() => {
|
||||||
|
for (const target of targets) {
|
||||||
|
const record = observed.get(target);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
||||||
|
}
|
||||||
|
record.callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
target,
|
||||||
|
} as IntersectionObserverEntry,
|
||||||
|
],
|
||||||
|
record.observer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
isObserved(target) {
|
isObserved(target) {
|
||||||
return observed.has(target);
|
return observed.has(target);
|
||||||
},
|
},
|
||||||
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
const thumbnails = entries.map((entry) =>
|
const thumbnails = entries.map((entry) =>
|
||||||
thumbnailElementForLabel(entry.label),
|
thumbnailElementForLabel(entry.label),
|
||||||
);
|
);
|
||||||
thumbnails.forEach((thumbnail) => {
|
for (const thumbnail of thumbnails) {
|
||||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||||
observer.enter(thumbnail);
|
}
|
||||||
});
|
observer.enterAll(thumbnails);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
});
|
});
|
||||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||||
const observer = installIntersectionObserverMock();
|
const observer = installIntersectionObserverMock();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@genarrative/ai-game-creator-shell",
|
"name": "@genarrative/ai-game-creator-shell",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.45",
|
"version": "0.1.47",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/start-tauri-dev.mjs",
|
"dev": "node scripts/start-tauri-dev.mjs",
|
||||||
|
|||||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.45"
|
version = "0.1.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-runtime-core",
|
"agent-runtime-core",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.45"
|
version = "0.1.47"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||||
|
|||||||
@@ -195,6 +195,20 @@ impl CodexAppServerStderrSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 派发 app-server 的收尾与中断任务。
|
||||||
|
///
|
||||||
|
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
||||||
|
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
||||||
|
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
||||||
|
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
||||||
|
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
||||||
|
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
||||||
|
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
||||||
|
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
||||||
|
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
||||||
|
tauri::async_runtime::spawn(task);
|
||||||
|
}
|
||||||
|
|
||||||
struct CodexTurnStartCancellation {
|
struct CodexTurnStartCancellation {
|
||||||
inner: Weak<CodexAppServerInner>,
|
inner: Weak<CodexAppServerInner>,
|
||||||
thread_id: String,
|
thread_id: String,
|
||||||
@@ -256,7 +270,7 @@ impl CodexTurnStartCancellation {
|
|||||||
};
|
};
|
||||||
let connection = CodexAppServerConnection { inner };
|
let connection = CodexAppServerConnection { inner };
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
let _ = connection
|
let _ = connection
|
||||||
.request(
|
.request(
|
||||||
"turn/interrupt",
|
"turn/interrupt",
|
||||||
@@ -3566,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let key = self.key.clone();
|
let key = self.key.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
let mut threads = connection.inner.threads.lock().await;
|
let mut threads = connection.inner.threads.lock().await;
|
||||||
if let Some(entry) = threads.get_mut(&key) {
|
if let Some(entry) = threads.get_mut(&key) {
|
||||||
if entry.thread_id == thread_id {
|
if entry.thread_id == thread_id {
|
||||||
@@ -3593,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
let turn_id = self.turn_id.clone();
|
let turn_id = self.turn_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
connection.inner.turns.lock().await.remove(&turn_id);
|
connection.inner.turns.lock().await.remove(&turn_id);
|
||||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||||
let _ = connection
|
let _ = connection
|
||||||
@@ -4489,6 +4503,19 @@ mod tests {
|
|||||||
assert!(table.select(&key, None).is_err());
|
assert!(table.select(&key, None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||||
|
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||||
|
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||||
|
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
||||||
|
#[test]
|
||||||
|
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
||||||
|
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
||||||
|
assert!(
|
||||||
|
joined.join().is_ok(),
|
||||||
|
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -527,10 +527,6 @@ fn process_design_batch(
|
|||||||
let result = if uncertain {
|
let result = if uncertain {
|
||||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||||
} else {
|
} else {
|
||||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
|
||||||
root,
|
|
||||||
"design.tool",
|
|
||||||
)?;
|
|
||||||
execute_design_tool(root, resources, session, &call)
|
execute_design_tool(root, resources, session, &call)
|
||||||
};
|
};
|
||||||
let error = result
|
let error = result
|
||||||
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
|
|||||||
finish_design_command(root, resources, session, active, run, emit).await
|
finish_design_command(root, resources, session, active, run, emit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn recover_uncertain_design_batch(
|
||||||
|
root: &Path,
|
||||||
|
resources: &DesignResources,
|
||||||
|
session: DesignSession,
|
||||||
|
active: File,
|
||||||
|
) -> Result<DesignView, String> {
|
||||||
|
finish_design_command(root, resources, session, active, true, |_| {}).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn decide_design_phase_at(
|
pub(crate) async fn decide_design_phase_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
resources: &DesignResources,
|
resources: &DesignResources,
|
||||||
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn hydrate_design_agent_session(
|
pub(crate) async fn hydrate_design_agent_session(
|
||||||
|
app: tauri::AppHandle,
|
||||||
project_path: String,
|
project_path: String,
|
||||||
) -> Result<Option<DesignView>, String> {
|
) -> Result<Option<DesignView>, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
|
|||||||
if session.project_id != project_id {
|
if session.project_id != project_id {
|
||||||
return Err("策划会话与当前项目不匹配".into());
|
return Err("策划会话与当前项目不匹配".into());
|
||||||
}
|
}
|
||||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
let Some(active) =
|
||||||
Ok(Some(design_view(&session, active.is_none())))
|
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||||||
|
else {
|
||||||
|
return Ok(Some(design_view(&session, true)));
|
||||||
|
};
|
||||||
|
if design_session_has_uncertain_batch(&session) {
|
||||||
|
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||||
|
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||||
|
return Ok(Some(view));
|
||||||
|
}
|
||||||
|
drop(active);
|
||||||
|
Ok(Some(design_view(&session, false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
||||||
|
let Some(batch) = session.pending_batch.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !batch.executing || batch.cursor >= batch.calls.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let call_id = batch.calls[batch.cursor].id.as_str();
|
||||||
|
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||||
|
&& !session.history.iter().any(|item| {
|
||||||
|
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||||
|
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||||
@@ -1958,4 +1989,94 @@ mod tests {
|
|||||||
.any(|message| message.text.contains("重试后继续")));
|
.any(|message| message.text.contains("重试后继续")));
|
||||||
assert!(next.session.last_error.is_none());
|
assert!(next.session.last_error.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||||
|
let (_temp, root, resources) = init_design_project();
|
||||||
|
execute_design_file_tool(
|
||||||
|
&root,
|
||||||
|
"write_file",
|
||||||
|
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
||||||
|
)
|
||||||
|
.expect("write concept");
|
||||||
|
let mut session = new_design_session("design-fake", "quality");
|
||||||
|
let call = platform_llm::LlmToolCall {
|
||||||
|
id: "interrupted-call".into(),
|
||||||
|
name: "patch_file".into(),
|
||||||
|
arguments: json!({
|
||||||
|
"path":"project/00_concept/design.md",
|
||||||
|
"old_text":"概念",
|
||||||
|
"new_text":"概念设计"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
session.history.push(json!({
|
||||||
|
"type":"function_call",
|
||||||
|
"call_id":call.id,
|
||||||
|
"name":call.name,
|
||||||
|
"arguments":call.arguments,
|
||||||
|
}));
|
||||||
|
session.messages = vec![DesignMessage {
|
||||||
|
id: "turn:user".into(),
|
||||||
|
role: "user".into(),
|
||||||
|
text: "继续".into(),
|
||||||
|
}];
|
||||||
|
session.turn = Some(DesignTurn {
|
||||||
|
id: "turn-recovery".into(),
|
||||||
|
pending: true,
|
||||||
|
request_index: 0,
|
||||||
|
attempt: 0,
|
||||||
|
});
|
||||||
|
session.pending_batch = Some(DesignToolBatch {
|
||||||
|
calls: vec![call],
|
||||||
|
cursor: 0,
|
||||||
|
executing: true,
|
||||||
|
});
|
||||||
|
assert!(design_session_has_uncertain_batch(&session));
|
||||||
|
write_design_session(&root, &session).expect("write interrupted session");
|
||||||
|
|
||||||
|
let _fake = fake_provider::install(
|
||||||
|
vec![Ok(fake_response(
|
||||||
|
"recovered-after-uncertain-tool",
|
||||||
|
"已读取文件并确认。",
|
||||||
|
Vec::new(),
|
||||||
|
))],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||||
|
try_open_game_creator_agent_runtime_task_lock_file(
|
||||||
|
&root,
|
||||||
|
".agent/design-agent/active.lock",
|
||||||
|
)
|
||||||
|
.expect("open active lock")
|
||||||
|
.expect("active lock is free")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("recover uncertain batch");
|
||||||
|
|
||||||
|
assert!(!view.running);
|
||||||
|
assert!(view.session.last_error.is_none());
|
||||||
|
let restored = read_design_session(&root)
|
||||||
|
.expect("read restored")
|
||||||
|
.expect("session");
|
||||||
|
assert!(restored.pending_batch.is_none());
|
||||||
|
assert!(!restored.turn.expect("turn").pending);
|
||||||
|
assert!(restored.history.iter().any(|item| {
|
||||||
|
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||||
|
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
||||||
|
&& item
|
||||||
|
.get("output")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|output| output.contains("执行结果未保存"))
|
||||||
|
}));
|
||||||
|
assert!(restored.history.iter().any(|item| {
|
||||||
|
item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||||
|
&& item.get("content").is_some()
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
||||||
|
.expect("read target")
|
||||||
|
== "概念"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
|
let mut edit_errors = Vec::new();
|
||||||
|
let mut valid_edits = 0;
|
||||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||||
|
if old == new {
|
||||||
|
edit_errors.push(format!(
|
||||||
|
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let count = content.matches(old).count();
|
let count = content.matches(old).count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
edit_errors.push(format!(
|
||||||
|
"edits[{index}] 原文未找到:{}{}",
|
||||||
|
display,
|
||||||
|
design_patch_location_hint(&content, old)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
return Err(format!(
|
let start = content.find(old).expect("count checked");
|
||||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
let line = design_patch_line_number(&content, start);
|
||||||
|
edit_errors.push(format!(
|
||||||
|
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||||
));
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let start = content.find(old).expect("count checked");
|
let start = content.find(old).expect("count checked");
|
||||||
let end = start + old.len();
|
let end = start + old.len();
|
||||||
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||||
{
|
{
|
||||||
return Err(format!(
|
edit_errors.push(format!(
|
||||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||||
));
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
matches.push((index, start, end));
|
matches.push((index, start, end));
|
||||||
|
valid_edits += 1;
|
||||||
let _ = new;
|
let _ = new;
|
||||||
}
|
}
|
||||||
|
if !edit_errors.is_empty() {
|
||||||
|
let shown = edit_errors.len().min(4);
|
||||||
|
let mut details = edit_errors[..shown].to_vec();
|
||||||
|
if shown < edit_errors.len() {
|
||||||
|
details.push(format!(
|
||||||
|
"另有 {} 个 edit 校验失败(详情省略)",
|
||||||
|
edit_errors.len() - shown
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if valid_edits > 0 {
|
||||||
|
details.push(format!(
|
||||||
|
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
details.push("本次未写入文件".to_string());
|
||||||
|
}
|
||||||
|
return Err(details.join("\n"));
|
||||||
|
}
|
||||||
let mut updated = content.clone();
|
let mut updated = content.clone();
|
||||||
for (index, start, end) in matches.into_iter().rev() {
|
for (index, start, end) in matches.into_iter().rev() {
|
||||||
let (_, new) = &normalized[index];
|
let (_, new) = &normalized[index];
|
||||||
@@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn design_patch_line_number(content: &str, start: usize) -> usize {
|
||||||
|
1 + content[..start]
|
||||||
|
.bytes()
|
||||||
|
.filter(|byte| *byte == b'\n')
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn design_patch_visible_line(line: &str) -> String {
|
||||||
|
line.replace('\t', "\\t").chars().take(180).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn design_patch_location_hint(content: &str, old: &str) -> String {
|
||||||
|
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = content
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| line.trim() == anchor)
|
||||||
|
.map(|(index, line)| (index + 1, line))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
|
||||||
|
if let Some(token) = token {
|
||||||
|
candidates = content
|
||||||
|
.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| line.trim().contains(token))
|
||||||
|
.map(|(index, line)| (index + 1, line))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return format!(
|
||||||
|
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
|
||||||
|
content.lines().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = candidates
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
let suffix = if candidates.len() > 2 {
|
||||||
|
format!("等 {} 处", candidates.len())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn list_design_workspace_files(
|
pub(crate) fn list_design_workspace_files(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||||
@@ -693,6 +783,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect_err("escape");
|
.expect_err("escape");
|
||||||
assert!(escaped.contains("路径"));
|
assert!(escaped.contains("路径"));
|
||||||
|
let mismatch = execute_design_file_tool(
|
||||||
|
root,
|
||||||
|
"patch_file",
|
||||||
|
&json!({
|
||||||
|
"path":"notes/design.md",
|
||||||
|
"edits":[
|
||||||
|
{"old_text":" 游戏设计","new_text":"游戏概念"},
|
||||||
|
{"old_text":"设计","new_text":"方案"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect_err("report all patch failures");
|
||||||
|
assert!(mismatch.contains("edits[0] 原文未找到"));
|
||||||
|
assert!(mismatch.contains("第 1 行:游戏设计"));
|
||||||
|
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
|
||||||
|
assert!(mismatch.contains("本次未写入文件"));
|
||||||
let patched = execute_design_file_tool(
|
let patched = execute_design_file_tool(
|
||||||
root,
|
root,
|
||||||
"patch_file",
|
"patch_file",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,9 @@ use super::*;
|
|||||||
|
|
||||||
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
|
||||||
OnceLock::new();
|
OnceLock::new();
|
||||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
|
||||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
|
||||||
|
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
|
||||||
> = OnceLock::new();
|
> = OnceLock::new();
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
|
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,
|
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
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_at, read_game_creator_agent_runtime_for_session_at,
|
||||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
|
||||||
set_game_creator_agent_runtime_update_app_handle,
|
set_game_creator_agent_runtime_update_app_handle,
|
||||||
start_game_creator_manifest_invalidation_event_sink,
|
start_game_creator_manifest_invalidation_event_sink,
|
||||||
validate_game_creator_manifest_invalidation_event_sink,
|
validate_game_creator_manifest_invalidation_event_sink,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
|
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(
|
fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||||
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
|
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
|
||||||
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
|
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
|
||||||
.get_or_init(|| Mutex::new(None))
|
.get_or_init(|| Mutex::new(Vec::new()))
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
|
|||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
|
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(())
|
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,
|
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)]
|
#[cfg(test)]
|
||||||
@@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
|
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)]
|
#[cfg(test)]
|
||||||
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
|
||||||
fn drop(&mut self) {
|
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> {
|
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
|
||||||
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
|
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
|
||||||
let Some(sink) = sink else {
|
if sinks.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
|
let event = GameCreatorManifestInvalidatedEvent {
|
||||||
token: sink.token,
|
|
||||||
event: GameCreatorManifestInvalidatedEvent {
|
|
||||||
project_path: root.to_string_lossy().into_owned(),
|
project_path: root.to_string_lossy().into_owned(),
|
||||||
agent_id: agent_id.to_string(),
|
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.clone(),
|
||||||
|
event: event.clone(),
|
||||||
};
|
};
|
||||||
let payload = serde_json::to_vec(&envelope)
|
let payload = serde_json::to_vec(&envelope)
|
||||||
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
|
||||||
|
|||||||
@@ -2219,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent)
|
|||||||
enum GameCreatorGuiRunnerShutdownOutcome {
|
enum GameCreatorGuiRunnerShutdownOutcome {
|
||||||
NotRequested,
|
NotRequested,
|
||||||
Requested,
|
Requested,
|
||||||
|
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
|
||||||
|
Retained,
|
||||||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2256,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error(
|
|||||||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||||||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||||||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||||||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
|
||||||
|
{
|
||||||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||||||
} else if error.contains("endpoint") {
|
} else if error.contains("endpoint") {
|
||||||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||||||
@@ -2272,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown<F>(
|
|||||||
shutdown: F,
|
shutdown: F,
|
||||||
) -> GameCreatorGuiRunnerShutdownOutcome
|
) -> GameCreatorGuiRunnerShutdownOutcome
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Result<(), String>,
|
F: FnOnce() -> Result<bool, String>,
|
||||||
{
|
{
|
||||||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||||||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||||||
}
|
}
|
||||||
match shutdown() {
|
match shutdown() {
|
||||||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||||||
|
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
|
||||||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||||||
classify_game_creator_gui_runner_shutdown_error(&error),
|
classify_game_creator_gui_runner_shutdown_error(&error),
|
||||||
),
|
),
|
||||||
@@ -2298,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
|||||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
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::NotRequested => {}
|
||||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||||
}
|
}
|
||||||
|
GameCreatorGuiRunnerShutdownOutcome::Retained => {
|
||||||
|
app_log!("agent.runner.gui_exit.retained_for_other_windows")
|
||||||
|
}
|
||||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||||
}
|
}
|
||||||
@@ -2611,27 +2621,25 @@ fn main() {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
setup_log.append("startup.runner.configure.complete");
|
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| {
|
.inspect_err(|error| {
|
||||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||||
setup_log.fail(&format!(
|
setup_log.fail(&format!(
|
||||||
"startup.runner.owner-lock.failed details={details}"
|
"startup.runner.participant-lock.failed details={details}"
|
||||||
));
|
));
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::AlreadyExists,
|
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");
|
setup_log.append("startup.runner.start.begin");
|
||||||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||||||
set_direct_thread_manager_app_handle(app.handle().clone());
|
set_direct_thread_manager_app_handle(app.handle().clone());
|
||||||
let manifest_event_sink =
|
let manifest_event_sink =
|
||||||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
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| {
|
.inspect_err(|error| {
|
||||||
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||||||
setup_log.fail(&format!(
|
setup_log.fail(&format!(
|
||||||
|
|||||||
@@ -12,21 +12,20 @@ pub(crate) use client::{
|
|||||||
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
|
||||||
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
configure_external_agent_runner, configure_external_agent_runner_read_only,
|
||||||
continue_external_agent_runner_action, ensure_external_agent_runner_started,
|
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,
|
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
|
||||||
pause_external_agent_runner, read_external_agent_runner_status,
|
pause_external_agent_runner, read_external_agent_runner_status,
|
||||||
require_external_agent_runner_configured_for_cli_runtime_write,
|
require_external_agent_runner_configured_for_cli_runtime_write,
|
||||||
require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner,
|
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, shutdown_external_agent_runner_for_client_exit,
|
||||||
shutdown_external_agent_runner_if_idle, steer_external_agent_runner,
|
shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle,
|
||||||
wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run,
|
steer_external_agent_runner, wake_external_agent_runner_pending,
|
||||||
|
wake_external_agent_runner_pending_for_run,
|
||||||
};
|
};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) use endpoint::validate_windows_regular_file_handle;
|
pub(crate) use endpoint::validate_windows_regular_file_handle;
|
||||||
pub(crate) use endpoint::{
|
pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process};
|
||||||
acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled,
|
|
||||||
external_agent_runner_is_server_process,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION};
|
pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION};
|
||||||
pub(crate) use server::{
|
pub(crate) use server::{
|
||||||
|
|||||||
@@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState {
|
|||||||
|
|
||||||
struct ExternalAgentRunnerGuiOwnerRegistration {
|
struct ExternalAgentRunnerGuiOwnerRegistration {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
claim_mode: ExternalAgentRunnerGuiOwnerClaimMode,
|
||||||
config_dir: PathBuf,
|
config_dir: PathBuf,
|
||||||
params: ExternalAgentRunnerRequestParams,
|
params: ExternalAgentRunnerRequestParams,
|
||||||
attached_boot_id: Option<String>,
|
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<
|
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
|
||||||
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||||
> = OnceLock::new();
|
> = OnceLock::new();
|
||||||
|
|
||||||
|
static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock<
|
||||||
|
Mutex<Option<ExternalAgentRunnerGuiParticipantLock>>,
|
||||||
|
> = OnceLock::new();
|
||||||
|
|
||||||
fn external_agent_runner_gui_owner_attachment_state(
|
fn external_agent_runner_gui_owner_attachment_state(
|
||||||
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
||||||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
|
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
|
||||||
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
|
.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(
|
pub(super) fn register_external_agent_runner_gui_owner_attachment(
|
||||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
|
claim_mode: ExternalAgentRunnerGuiOwnerClaimMode,
|
||||||
mut params: ExternalAgentRunnerRequestParams,
|
mut params: ExternalAgentRunnerRequestParams,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut state = lock_unpoisoned(state);
|
let mut state = lock_unpoisoned(state);
|
||||||
state.generation = state.generation.wrapping_add(1);
|
state.generation = state.generation.wrapping_add(1);
|
||||||
let generation = state.generation;
|
let generation = state.generation;
|
||||||
|
if params.gui_owner_session_revision.is_none() {
|
||||||
params.gui_owner_session_revision = Some(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)?;
|
|
||||||
}
|
}
|
||||||
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
|
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
|
||||||
generation,
|
generation,
|
||||||
|
claim_mode,
|
||||||
config_dir: config_dir.to_path_buf(),
|
config_dir: config_dir.to_path_buf(),
|
||||||
params,
|
params,
|
||||||
attached_boot_id: None,
|
attached_boot_id: None,
|
||||||
@@ -61,6 +102,29 @@ pub(super) fn register_external_agent_runner_gui_owner_attachment(
|
|||||||
Ok(())
|
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>(
|
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
|
||||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
@@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F
|
|||||||
attach: F,
|
attach: F,
|
||||||
) -> Result<(), String>
|
) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>,
|
F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>,
|
||||||
{
|
{
|
||||||
let Some((generation, params)) = ({
|
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);
|
let state = lock_unpoisoned(state);
|
||||||
state.registration.as_ref().and_then(|registration| {
|
state.registration.as_ref().and_then(|registration| {
|
||||||
(registration.config_dir == config_dir
|
(registration.config_dir == config_dir
|
||||||
&& registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()))
|
&& registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()))
|
||||||
.then(|| (registration.generation, registration.params.clone()))
|
.then(|| {
|
||||||
|
(
|
||||||
|
registration.generation,
|
||||||
|
registration.params.clone(),
|
||||||
|
registration.claim_mode,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}) else {
|
}) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
attach(endpoint, params)?;
|
match attach(endpoint, params) {
|
||||||
|
Ok(()) => {
|
||||||
let mut state = lock_unpoisoned(state);
|
let mut state = lock_unpoisoned(state);
|
||||||
if let Some(registration) = state.registration.as_mut() {
|
if let Some(registration) = state.registration.as_mut() {
|
||||||
if registration.generation == generation && registration.config_dir == config_dir {
|
if registration.generation == generation
|
||||||
|
&& registration.config_dir == config_dir
|
||||||
|
{
|
||||||
registration.attached_boot_id = Some(endpoint.boot_id.clone());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,7 +1072,6 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
|
|||||||
|
|
||||||
pub(crate) fn attach_external_agent_runner_gui_owner(
|
pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||||
event_sink: &GameCreatorManifestInvalidationEventSink,
|
event_sink: &GameCreatorManifestInvalidationEventSink,
|
||||||
gui_owner_epoch: &str,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
||||||
.store(true, std::sync::atomic::Ordering::Release);
|
.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()
|
let config_dir = external_agent_runner_config_dir()
|
||||||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||||||
let platform_session = crate::current_platform_session();
|
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(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
external_agent_runner_gui_owner_attachment_state(),
|
external_agent_runner_gui_owner_attachment_state(),
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(event_sink.port),
|
event_sink_port: Some(event_sink.port),
|
||||||
event_sink_token: Some(event_sink.token.clone()),
|
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
|
platform_user_id: platform_session
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|session| session.user_id.clone()),
|
.map(|session| session.user_id.clone()),
|
||||||
@@ -1122,7 +1242,7 @@ pub(super) fn remember_external_agent_runner_platform_session(
|
|||||||
session,
|
session,
|
||||||
identity_generation,
|
identity_generation,
|
||||||
revision,
|
revision,
|
||||||
write_external_agent_runner_gui_owner_claim_atomic,
|
publish_external_agent_runner_gui_owner_claim,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1251,7 @@ pub(super) fn remember_external_agent_runner_platform_session_with(
|
|||||||
session: Option<(&str, &str, &str)>,
|
session: Option<(&str, &str, &str)>,
|
||||||
identity_generation: u64,
|
identity_generation: u64,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
|
publish_claim: impl FnOnce(&Path, u64) -> Result<ExternalAgentRunnerGuiOwnerClaim, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut state = lock_unpoisoned(state);
|
let mut state = lock_unpoisoned(state);
|
||||||
let Some(registration) = state.registration.as_ref() else {
|
let Some(registration) = state.registration.as_ref() else {
|
||||||
@@ -1167,21 +1287,23 @@ pub(super) fn remember_external_agent_runner_platform_session_with(
|
|||||||
}
|
}
|
||||||
state.generation = state.generation.wrapping_add(1);
|
state.generation = state.generation.wrapping_add(1);
|
||||||
let registration_generation = state.generation;
|
let registration_generation = state.generation;
|
||||||
let claim = state.registration.as_ref().and_then(|registration| {
|
// 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。
|
||||||
registration
|
// 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。
|
||||||
.params
|
// 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身)
|
||||||
.gui_owner_epoch
|
// 不写任何 claim 文件。
|
||||||
.as_deref()
|
let published_claim = state
|
||||||
.map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string()))
|
.registration
|
||||||
});
|
.as_ref()
|
||||||
if let Some((config_dir, owner_epoch)) = claim {
|
.filter(|registration| registration.params.gui_owner_epoch.is_some())
|
||||||
write_claim(&config_dir, &owner_epoch, registration_generation)?;
|
.map(|registration| registration.config_dir.clone())
|
||||||
}
|
.map(|config_dir| publish_claim(&config_dir, registration_generation))
|
||||||
|
.transpose()?;
|
||||||
let registration = state
|
let registration = state
|
||||||
.registration
|
.registration
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("checked GUI owner registration must remain present while locked");
|
.expect("checked GUI owner registration must remain present while locked");
|
||||||
registration.generation = registration_generation;
|
registration.generation = registration_generation;
|
||||||
|
registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish;
|
||||||
registration.attached_boot_id = None;
|
registration.attached_boot_id = None;
|
||||||
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
|
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
|
||||||
registration.params.platform_access_token =
|
registration.params.platform_access_token =
|
||||||
@@ -1190,7 +1312,10 @@ pub(super) fn remember_external_agent_runner_platform_session_with(
|
|||||||
session.map(|(_, _, api_base_url)| api_base_url.to_string());
|
session.map(|(_, _, api_base_url)| api_base_url.to_string());
|
||||||
registration.params.platform_auth_generation = Some(identity_generation);
|
registration.params.platform_auth_generation = Some(identity_generation);
|
||||||
registration.params.platform_auth_revision = Some(revision);
|
registration.params.platform_auth_revision = Some(revision);
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,6 +1409,25 @@ pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<bool, S
|
|||||||
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
|
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(
|
pub(super) fn wait_for_external_agent_runner(
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
child: &mut Child,
|
child: &mut Child,
|
||||||
@@ -1322,34 +1466,12 @@ pub(super) fn ensure_external_agent_runner(
|
|||||||
) -> Result<ExternalAgentRunnerEndpoint, String> {
|
) -> Result<ExternalAgentRunnerEndpoint, String> {
|
||||||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||||||
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
|
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
|
||||||
if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) {
|
if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint(
|
||||||
match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) {
|
config_dir,
|
||||||
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_path,
|
||||||
&endpoint,
|
&executable_fingerprint,
|
||||||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
)? {
|
||||||
.load(std::sync::atomic::Ordering::Acquire),
|
return Ok(endpoint);
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mut launched = launch_external_agent_runner(config_dir)?;
|
let mut launched = launch_external_agent_runner(config_dir)?;
|
||||||
match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) {
|
match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) {
|
||||||
@@ -1366,11 +1488,57 @@ pub(super) fn ensure_external_agent_runner(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = launched.child.kill();
|
let _ = launched.child.kill();
|
||||||
let _ = launched.child.wait();
|
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)
|
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> {
|
pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef<Path>) -> Result<(), String> {
|
||||||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||||||
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
|
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 super::{endpoint::*, project_owner::*, protocol::*, state::*};
|
||||||
use crate::{
|
use crate::{
|
||||||
install_game_creator_manifest_invalidation_event_sink,
|
register_game_creator_manifest_invalidation_event_sink,
|
||||||
validate_game_creator_manifest_invalidation_event_sink,
|
validate_game_creator_manifest_invalidation_event_sink,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -116,9 +116,9 @@ fn apply_external_agent_runner_gui_owner_attachment(
|
|||||||
.gui_owner_session_revision
|
.gui_owner_session_revision
|
||||||
.ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?;
|
.ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?;
|
||||||
let config_dir = state
|
let config_dir = state
|
||||||
.gui_owner_lock_path
|
.gui_participant_lock_path
|
||||||
.parent()
|
.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 mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
|
||||||
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?;
|
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?;
|
||||||
if durable_claim.owner_epoch != requested_epoch
|
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());
|
return Err("Agent Runner GUI owner claim 已过期".to_string());
|
||||||
}
|
}
|
||||||
let requested_claim = (requested_epoch.to_string(), requested_revision);
|
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 (
|
let result = match (
|
||||||
params.platform_user_id.as_deref(),
|
params.platform_user_id.as_deref(),
|
||||||
params.platform_access_token.as_deref(),
|
params.platform_access_token.as_deref(),
|
||||||
@@ -168,7 +175,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
|
|||||||
crate::clear_platform_session_checked(identity_generation, revision)
|
crate::clear_platform_session_checked(identity_generation, revision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(None, None, None, None, None) if replace_claim => {
|
(None, None, None, None, None) if epoch_changed => {
|
||||||
crate::clear_platform_session_for_gui_owner(0, 0);
|
crate::clear_platform_session_for_gui_owner(0, 0);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -194,7 +201,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
|
|||||||
return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
|
return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
|
||||||
}
|
}
|
||||||
if let Some(event_sink) = event_sink {
|
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);
|
*active_claim = Some(requested_claim);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -204,9 +211,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
|
|||||||
state: &ExternalAgentRunnerServerState,
|
state: &ExternalAgentRunnerServerState,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let config_dir = state
|
let config_dir = state
|
||||||
.gui_owner_lock_path
|
.gui_participant_lock_path
|
||||||
.parent()
|
.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 mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim);
|
||||||
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir);
|
let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir);
|
||||||
let matches = durable_claim.as_ref().is_ok_and(|claim| {
|
let matches = durable_claim.as_ref().is_ok_and(|claim| {
|
||||||
@@ -777,7 +784,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"runner.attach_gui_owner" => {
|
"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) => {
|
Ok(true) => {
|
||||||
let event_sink = request
|
let event_sink = request
|
||||||
.params
|
.params
|
||||||
@@ -819,7 +826,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
|||||||
Ok(false) => ExternalAgentRunnerResponse::failure(
|
Ok(false) => ExternalAgentRunnerResponse::failure(
|
||||||
&request.request_id,
|
&request.request_id,
|
||||||
"gui-owner-missing",
|
"gui-owner-missing",
|
||||||
"Agent Runner 未检测到活跃 GUI owner 锁",
|
"Agent Runner 未检测到活跃的 AGC 界面进程",
|
||||||
),
|
),
|
||||||
Err(error) => ExternalAgentRunnerResponse::failure(
|
Err(error) => ExternalAgentRunnerResponse::failure(
|
||||||
&request.request_id,
|
&request.request_id,
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Mutex;
|
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>> {
|
pub(super) fn external_agent_runner_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
|
||||||
EXTERNAL_AGENT_RUNNER_CONFIG_DIR.get_or_init(|| Mutex::new(None))
|
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)
|
config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf {
|
pub(super) fn external_agent_runner_gui_participant_lock_path(config_dir: &Path) -> PathBuf {
|
||||||
config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)
|
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 {
|
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)
|
Ok(claim)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result<bool, String> {
|
/// 独占探测:返回 `true` 表示仍有界面进程持有该参与锁。
|
||||||
match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? {
|
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) => {
|
Some(lock) => {
|
||||||
drop(lock);
|
drop(lock);
|
||||||
Ok(false)
|
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)]
|
#[cfg(unix)]
|
||||||
pub(super) fn try_open_external_agent_runner_lock(
|
pub(super) fn open_external_agent_runner_lock_file(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
mode: ExternalAgentRunnerLockMode,
|
||||||
) -> Result<Option<File>, String> {
|
) -> Result<Option<File>, String> {
|
||||||
use std::os::fd::AsRawFd;
|
use std::os::fd::AsRawFd;
|
||||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||||
@@ -809,14 +826,30 @@ pub(super) fn try_open_external_agent_runner_lock(
|
|||||||
path.display()
|
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.
|
// 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 {
|
if result == 0 {
|
||||||
return Ok(Some(file));
|
return Ok(Some(file));
|
||||||
}
|
}
|
||||||
let error = io::Error::last_os_error();
|
let error = io::Error::last_os_error();
|
||||||
if error.kind() == io::ErrorKind::WouldBlock {
|
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 {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"获取 {label} 系统锁失败:{}: {error}",
|
"获取 {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)]
|
#[cfg(windows)]
|
||||||
pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool {
|
pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool {
|
||||||
matches!(error.raw_os_error(), Some(32 | 33))
|
matches!(error.raw_os_error(), Some(32 | 33))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(super) fn try_open_external_agent_runner_lock(
|
pub(super) fn open_external_agent_runner_lock_file(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
mode: ExternalAgentRunnerLockMode,
|
||||||
) -> Result<Option<File>, String> {
|
) -> Result<Option<File>, String> {
|
||||||
use std::os::windows::fs::OpenOptionsExt;
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
|
||||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
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
|
let parent = path
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?;
|
.ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?;
|
||||||
let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?;
|
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 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);
|
let gui_participant_lock_path =
|
||||||
if path != runner_lock_path && path != gui_owner_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!(
|
return Err(format!(
|
||||||
"{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}",
|
"{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}",
|
||||||
runner_lock_path.display(),
|
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()
|
match OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.share_mode(0)
|
.share_mode(share_mode)
|
||||||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||||||
.open(path)
|
.open(path)
|
||||||
{
|
{
|
||||||
@@ -875,16 +927,30 @@ pub(super) fn try_open_external_agent_runner_lock(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_windows_regular_file_handle(&file, label)?;
|
validate_windows_regular_file_handle(&file, label)?;
|
||||||
// share_mode(0) gives this process an exclusive handle. At this point the fixed
|
// The fixed lock path is known to be a single-link, non-reparse regular file
|
||||||
// lock path is known to be a stale, single-link, non-reparse regular file inside
|
// inside the current TokenUser's private AppData. Repairing its owner is
|
||||||
// the current TokenUser's private AppData. Repairing its owner is therefore safe
|
// therefore safe and is required when Windows creates it with
|
||||||
// and is required when Windows creates it with TokenOwner=Administrators.
|
// TokenOwner=Administrators.
|
||||||
crate::initialize_windows_game_creator_file_owner_for_current_user(path)?;
|
crate::initialize_windows_game_creator_file_owner_for_current_user(path)?;
|
||||||
validate_windows_regular_file_handle(&file, label)?;
|
validate_windows_regular_file_handle(&file, label)?;
|
||||||
crate::secure_windows_game_creator_path_for_current_user(path, false, false)?;
|
crate::secure_windows_game_creator_path_for_current_user(path, false, false)?;
|
||||||
Ok(Some(file))
|
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!(
|
Err(error) => Err(format!(
|
||||||
"安全打开 {label} 失败:{}: {error}",
|
"安全打开 {label} 失败:{}: {error}",
|
||||||
path.display()
|
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)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
pub(super) fn try_open_external_agent_runner_lock(
|
pub(super) fn try_open_external_agent_runner_lock(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Result<Option<File>, String> {
|
) -> 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(
|
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 })
|
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,
|
config_dir: &Path,
|
||||||
) -> Result<ExternalAgentRunnerGuiOwnerLock, String> {
|
) -> Result<ExternalAgentRunnerGuiParticipantLock, String> {
|
||||||
let path = external_agent_runner_gui_owner_lock_path(config_dir);
|
let path = external_agent_runner_gui_participant_lock_path(config_dir);
|
||||||
let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")?
|
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT;
|
||||||
else {
|
let mut last_error = "AGC 界面参与锁未知失败".to_string();
|
||||||
return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string());
|
loop {
|
||||||
};
|
match open_external_agent_runner_lock_file(
|
||||||
let owner_epoch = uuid::Uuid::new_v4().to_string();
|
&path,
|
||||||
let acquired_at = unix_millis();
|
"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!({
|
let diagnostic = serde_json::to_vec(&json!({
|
||||||
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
"protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||||
"pid": std::process::id(),
|
"pid": std::process::id(),
|
||||||
"ownerEpoch": owner_epoch,
|
"instanceId": uuid::Uuid::new_v4().to_string(),
|
||||||
"acquiredAt": acquired_at,
|
"acquiredAt": unix_millis(),
|
||||||
}))
|
}))
|
||||||
.map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?;
|
.map_err(|error| format!("生成 AGC 界面参与锁信息失败:{error}"))?;
|
||||||
file.set_len(0)
|
file.set_len(0)
|
||||||
.and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ()))
|
.and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ()))
|
||||||
.and_then(|_| file.write_all(&diagnostic))
|
.and_then(|_| file.write_all(&diagnostic))
|
||||||
.and_then(|_| file.sync_data())
|
.and_then(|_| file.sync_data())
|
||||||
.map_err(|error| {
|
.map_err(|error| format!("写入 AGC 界面参与锁信息失败:{}: {error}", path.display()))
|
||||||
format!(
|
}
|
||||||
"写入 Agent Runner GUI owner 锁信息失败:{}: {error}",
|
|
||||||
path.display()
|
/// 发布新的 durable claim:新 epoch + 本次会话 revision。
|
||||||
)
|
///
|
||||||
})?;
|
/// 发布是“谁改动登录态谁成为新 epoch 权威”的实现;并发发布以最后一次
|
||||||
write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?;
|
/// 成功写入为准,落败窗口按最新 claim 重试。
|
||||||
Ok(ExternalAgentRunnerGuiOwnerLock {
|
pub(crate) fn publish_external_agent_runner_gui_owner_claim(
|
||||||
_file: file,
|
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,
|
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(
|
pub(super) fn write_external_agent_runner_gui_owner_claim_atomic(
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
owner_epoch: &str,
|
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_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_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
||||||
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str =
|
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str =
|
||||||
"agent-runner.gui-owner.lock";
|
"agent-runner.gui-participant.lock";
|
||||||
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str =
|
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str =
|
||||||
"agent-runner.gui-owner.claim.json";
|
"agent-runner.gui-owner.claim.json";
|
||||||
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock";
|
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) {
|
if !state.gui_owner_attached.load(Ordering::Acquire) {
|
||||||
return false;
|
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) => {
|
Ok(true) => {
|
||||||
let _ = validate_external_agent_runner_gui_owner_claim_current(state);
|
let _ = validate_external_agent_runner_gui_owner_claim_current(state);
|
||||||
false
|
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(
|
let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner(
|
||||||
gui_owner_required,
|
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,
|
&config_dir,
|
||||||
))?,
|
))?,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState {
|
|||||||
pub(super) draining: AtomicBool,
|
pub(super) draining: AtomicBool,
|
||||||
pub(super) active_connections: AtomicUsize,
|
pub(super) active_connections: AtomicUsize,
|
||||||
pub(super) known_roots: Mutex<BTreeSet<PathBuf>>,
|
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:
|
pub(super) project_execution_owners:
|
||||||
Mutex<BTreeMap<PathBuf, ExternalAgentRunnerProjectExecutionOwnerEntry>>,
|
Mutex<BTreeMap<PathBuf, ExternalAgentRunnerProjectExecutionOwnerEntry>>,
|
||||||
project_execution_owner_recovery_changed: Condvar,
|
project_execution_owner_recovery_changed: Condvar,
|
||||||
@@ -80,10 +80,10 @@ impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> {
|
|||||||
|
|
||||||
impl ExternalAgentRunnerServerState {
|
impl ExternalAgentRunnerServerState {
|
||||||
pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self {
|
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()
|
.parent()
|
||||||
.map(external_agent_runner_gui_owner_lock_path)
|
.map(external_agent_runner_gui_participant_lock_path)
|
||||||
.unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME));
|
.unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME));
|
||||||
Self {
|
Self {
|
||||||
endpoint_path,
|
endpoint_path,
|
||||||
endpoint: Mutex::new(endpoint),
|
endpoint: Mutex::new(endpoint),
|
||||||
@@ -94,7 +94,7 @@ impl ExternalAgentRunnerServerState {
|
|||||||
draining: AtomicBool::new(false),
|
draining: AtomicBool::new(false),
|
||||||
active_connections: AtomicUsize::new(0),
|
active_connections: AtomicUsize::new(0),
|
||||||
known_roots: Mutex::new(BTreeSet::new()),
|
known_roots: Mutex::new(BTreeSet::new()),
|
||||||
gui_owner_lock_path,
|
gui_participant_lock_path,
|
||||||
project_execution_owners: Mutex::new(BTreeMap::new()),
|
project_execution_owners: Mutex::new(BTreeMap::new()),
|
||||||
project_execution_owner_recovery_changed: Condvar::new(),
|
project_execution_owner_recovery_changed: Condvar::new(),
|
||||||
write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()),
|
write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()),
|
||||||
@@ -231,16 +231,10 @@ pub(super) struct ExternalAgentRunnerInstanceLock {
|
|||||||
pub(super) _file: File,
|
pub(super) _file: File,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 界面进程持有的参与锁。共享句柄,同一 AppData 可同时存在多个窗口。
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ExternalAgentRunnerGuiOwnerLock {
|
pub(crate) struct ExternalAgentRunnerGuiParticipantLock {
|
||||||
pub(super) _file: File,
|
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 {
|
pub(super) struct ExternalAgentRunnerProjectOwnerStorage {
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf {
|
|||||||
.expect("prepare private runner AppData")
|
.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(
|
fn acquire_project_owner_after_release(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
boot_id: &str,
|
boot_id: &str,
|
||||||
@@ -574,7 +591,12 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() {
|
|||||||
event_sink_token: Some(event_sink_token.clone()),
|
event_sink_token: Some(event_sink_token.clone()),
|
||||||
..ExternalAgentRunnerRequestParams::default()
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
};
|
};
|
||||||
register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params)
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
|
&state,
|
||||||
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
|
params,
|
||||||
|
)
|
||||||
.expect("register GUI owner attachment");
|
.expect("register GUI owner attachment");
|
||||||
|
|
||||||
let calls = std::cell::RefCell::new(Vec::new());
|
let calls = std::cell::RefCell::new(Vec::new());
|
||||||
@@ -657,6 +679,7 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_330),
|
event_sink_port: Some(31_330),
|
||||||
event_sink_token: Some("f".repeat(64)),
|
event_sink_token: Some("f".repeat(64)),
|
||||||
@@ -729,6 +752,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_331),
|
event_sink_port: Some(31_331),
|
||||||
event_sink_token: Some("d".repeat(64)),
|
event_sink_token: Some("d".repeat(64)),
|
||||||
@@ -781,6 +805,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
platform_user_id: Some("user-a".to_string()),
|
platform_user_id: Some("user-a".to_string()),
|
||||||
platform_access_token: Some("token-a".to_string()),
|
platform_access_token: Some("token-a".to_string()),
|
||||||
@@ -834,8 +859,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
|
|||||||
fn gui_owner_platform_session_payload_clears_runner_session() {
|
fn gui_owner_platform_session_payload_clears_runner_session() {
|
||||||
let directory = unique_test_directory();
|
let directory = unique_test_directory();
|
||||||
let config_dir = private_runner_test_config_dir(&directory);
|
let config_dir = private_runner_test_config_dir(&directory);
|
||||||
let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire platform-session clear owner");
|
|
||||||
let state = ExternalAgentRunnerServerState::new(
|
let state = ExternalAgentRunnerServerState::new(
|
||||||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||||
test_endpoint("platform-clear-token", "platform-clear-boot", 31_333),
|
test_endpoint("platform-clear-token", "platform-clear-boot", 31_333),
|
||||||
@@ -848,7 +872,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() {
|
|||||||
apply_external_agent_runner_gui_owner_platform_session(
|
apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&ExternalAgentRunnerRequestParams {
|
||||||
gui_owner_epoch: Some(owner.owner_epoch().to_string()),
|
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||||||
gui_owner_session_revision: Some(0),
|
gui_owner_session_revision: Some(0),
|
||||||
platform_auth_generation: Some(2),
|
platform_auth_generation: Some(2),
|
||||||
platform_auth_revision: Some(2),
|
platform_auth_revision: Some(2),
|
||||||
@@ -863,8 +887,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() {
|
|||||||
fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
|
fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
|
||||||
let directory = unique_test_directory();
|
let directory = unique_test_directory();
|
||||||
let config_dir = private_runner_test_config_dir(&directory);
|
let config_dir = private_runner_test_config_dir(&directory);
|
||||||
let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire partial-session owner");
|
|
||||||
let state = ExternalAgentRunnerServerState::new(
|
let state = ExternalAgentRunnerServerState::new(
|
||||||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||||
test_endpoint("platform-partial-token", "platform-partial-boot", 31_334),
|
test_endpoint("platform-partial-token", "platform-partial-boot", 31_334),
|
||||||
@@ -878,7 +901,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
|
|||||||
let error = apply_external_agent_runner_gui_owner_platform_session(
|
let error = apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&ExternalAgentRunnerRequestParams {
|
||||||
gui_owner_epoch: Some(owner.owner_epoch().to_string()),
|
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||||||
gui_owner_session_revision: Some(0),
|
gui_owner_session_revision: Some(0),
|
||||||
platform_user_id: Some("runner-owner-b".to_string()),
|
platform_user_id: Some("runner-owner-b".to_string()),
|
||||||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||||||
@@ -905,9 +928,8 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old
|
|||||||
"runner-token-seed",
|
"runner-token-seed",
|
||||||
"https://dev.genarrative.world",
|
"https://dev.genarrative.world",
|
||||||
);
|
);
|
||||||
let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner_a = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire old GUI owner epoch");
|
let owner_a_epoch = owner_a.owner_epoch.clone();
|
||||||
let owner_a_epoch = owner_a.owner_epoch().to_string();
|
|
||||||
apply_external_agent_runner_gui_owner_platform_session(
|
apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&ExternalAgentRunnerRequestParams {
|
||||||
@@ -924,12 +946,11 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old
|
|||||||
.expect("old GUI installs high-generation owner A");
|
.expect("old GUI installs high-generation owner A");
|
||||||
drop(owner_a);
|
drop(owner_a);
|
||||||
|
|
||||||
let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner_b = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire new GUI owner epoch");
|
|
||||||
apply_external_agent_runner_gui_owner_platform_session(
|
apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&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),
|
gui_owner_session_revision: Some(0),
|
||||||
platform_user_id: Some("runner-owner-b".to_string()),
|
platform_user_id: Some("runner-owner-b".to_string()),
|
||||||
platform_access_token: Some("runner-token-b".to_string()),
|
platform_access_token: Some("runner-token-b".to_string()),
|
||||||
@@ -976,8 +997,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
|
|||||||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||||
test_endpoint(token, "platform-claim-gate-boot", 31_337),
|
test_endpoint(token, "platform-claim-gate-boot", 31_337),
|
||||||
);
|
);
|
||||||
let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire claim gate owner");
|
|
||||||
let _session = crate::install_test_platform_session(
|
let _session = crate::install_test_platform_session(
|
||||||
"runner-owner-seed",
|
"runner-owner-seed",
|
||||||
"runner-token-seed",
|
"runner-token-seed",
|
||||||
@@ -986,7 +1006,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
|
|||||||
apply_external_agent_runner_gui_owner_platform_session(
|
apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&ExternalAgentRunnerRequestParams {
|
||||||
gui_owner_epoch: Some(owner.owner_epoch().to_string()),
|
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||||||
gui_owner_session_revision: Some(0),
|
gui_owner_session_revision: Some(0),
|
||||||
platform_user_id: Some("runner-owner-a".to_string()),
|
platform_user_id: Some("runner-owner-a".to_string()),
|
||||||
platform_access_token: Some("runner-token-a".to_string()),
|
platform_access_token: Some("runner-token-a".to_string()),
|
||||||
@@ -999,7 +1019,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
|
|||||||
.expect("attach owner A claim");
|
.expect("attach owner A claim");
|
||||||
state.gui_owner_attached.store(true, Ordering::Release);
|
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");
|
.expect("advance durable claim before reattach");
|
||||||
assert!(
|
assert!(
|
||||||
!external_agent_runner_shutdown_if_gui_owner_lost(&state)
|
!external_agent_runner_shutdown_if_gui_owner_lost(&state)
|
||||||
@@ -1010,7 +1030,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_
|
|||||||
apply_external_agent_runner_gui_owner_platform_session(
|
apply_external_agent_runner_gui_owner_platform_session(
|
||||||
&state,
|
&state,
|
||||||
&ExternalAgentRunnerRequestParams {
|
&ExternalAgentRunnerRequestParams {
|
||||||
gui_owner_epoch: Some(owner.owner_epoch().to_string()),
|
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||||||
gui_owner_session_revision: Some(1),
|
gui_owner_session_revision: Some(1),
|
||||||
platform_user_id: Some("runner-owner-b".to_string()),
|
platform_user_id: Some("runner-owner-b".to_string()),
|
||||||
platform_access_token: Some("runner-token-b".to_string()),
|
platform_access_token: Some("runner-token-b".to_string()),
|
||||||
@@ -1057,14 +1077,14 @@ fn failed_platform_session_sync_fences_runner_before_returning_error() {
|
|||||||
fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
|
fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
|
||||||
let directory = unique_test_directory();
|
let directory = unique_test_directory();
|
||||||
let config_dir = private_runner_test_config_dir(&directory);
|
let config_dir = private_runner_test_config_dir(&directory);
|
||||||
let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||||||
.expect("acquire claim-write failure owner");
|
|
||||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
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_user_id: Some("runner-owner-a".to_string()),
|
||||||
platform_access_token: Some("runner-token-a".to_string()),
|
platform_access_token: Some("runner-token-a".to_string()),
|
||||||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||||||
@@ -1087,7 +1107,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
|
|||||||
)),
|
)),
|
||||||
2,
|
2,
|
||||||
2,
|
2,
|
||||||
|_, _, _| Err("injected durable claim write failure".to_string()),
|
|_, _| Err("injected durable claim write failure".to_string()),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|| {
|
|| {
|
||||||
@@ -1136,6 +1156,7 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams::default(),
|
ExternalAgentRunnerRequestParams::default(),
|
||||||
)
|
)
|
||||||
.expect("register GUI owner attachment");
|
.expect("register GUI owner attachment");
|
||||||
@@ -1184,6 +1205,7 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_322),
|
event_sink_port: Some(31_322),
|
||||||
event_sink_token: Some("c".repeat(64)),
|
event_sink_token: Some("c".repeat(64)),
|
||||||
@@ -1233,6 +1255,7 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
&config_dir,
|
&config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_323),
|
event_sink_port: Some(31_323),
|
||||||
event_sink_token: Some("d".repeat(64)),
|
event_sink_token: Some("d".repeat(64)),
|
||||||
@@ -1285,6 +1308,7 @@ fn gui_owner_registration_does_not_cross_config_dirs() {
|
|||||||
register_external_agent_runner_gui_owner_attachment(
|
register_external_agent_runner_gui_owner_attachment(
|
||||||
&state,
|
&state,
|
||||||
®istered_config_dir,
|
®istered_config_dir,
|
||||||
|
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||||||
ExternalAgentRunnerRequestParams {
|
ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_324),
|
event_sink_port: Some(31_324),
|
||||||
event_sink_token: Some(event_sink_token.clone()),
|
event_sink_token: Some(event_sink_token.clone()),
|
||||||
@@ -1334,18 +1358,116 @@ fn gui_owner_registration_does_not_cross_config_dirs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 directory = unique_test_directory();
|
||||||
let config_dir = private_runner_test_config_dir(&directory);
|
let config_dir = private_runner_test_config_dir(&directory);
|
||||||
let first =
|
let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir);
|
||||||
acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("first GUI owns AppData");
|
assert!(!external_agent_runner_lock_is_held(&participant_lock_path)
|
||||||
let error = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
.expect("probe without any window"));
|
||||||
.expect_err("second GUI must not share the same Runner owner");
|
|
||||||
assert!(error.contains("其他进程运行"));
|
|
||||||
|
|
||||||
|
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);
|
drop(first);
|
||||||
acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
assert!(
|
||||||
.expect("GUI owner lock is recoverable after the first frontend exits");
|
!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),
|
||||||
|
platform_auth_revision: Some(7),
|
||||||
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("first window installs its session");
|
||||||
|
assert_eq!(
|
||||||
|
crate::current_platform_session()
|
||||||
|
.map(|session| (session.user_id, session.identity_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.identity_generation)),
|
||||||
|
Some(("runner-owner-a".to_string(), 7)),
|
||||||
|
"同一 claim 的第二个窗口不得清空平台登录态"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1359,8 +1481,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||||||
test_endpoint(token, "gui-owner-monitor-boot", 31319),
|
test_endpoint(token, "gui-owner-monitor-boot", 31319),
|
||||||
);
|
);
|
||||||
let owner =
|
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||||||
acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("acquire GUI owner lock");
|
|
||||||
let attached = handle_external_agent_runner_request(
|
let attached = handle_external_agent_runner_request(
|
||||||
ExternalAgentRunnerRequest {
|
ExternalAgentRunnerRequest {
|
||||||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||||||
@@ -1370,7 +1491,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
params: ExternalAgentRunnerRequestParams {
|
params: ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_318),
|
event_sink_port: Some(31_318),
|
||||||
event_sink_token: Some("b".repeat(64)),
|
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),
|
gui_owner_session_revision: Some(0),
|
||||||
..ExternalAgentRunnerRequestParams::default()
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
},
|
},
|
||||||
@@ -1390,7 +1511,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")
|
!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");
|
.expect("advance owner claim revision");
|
||||||
let replacement = handle_external_agent_runner_request(
|
let replacement = handle_external_agent_runner_request(
|
||||||
ExternalAgentRunnerRequest {
|
ExternalAgentRunnerRequest {
|
||||||
@@ -1401,7 +1522,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
params: ExternalAgentRunnerRequestParams {
|
params: ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_319),
|
event_sink_port: Some(31_319),
|
||||||
event_sink_token: Some("c".repeat(64)),
|
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),
|
gui_owner_session_revision: Some(1),
|
||||||
..ExternalAgentRunnerRequestParams::default()
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
},
|
},
|
||||||
@@ -1410,11 +1531,18 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
);
|
);
|
||||||
assert!(replacement.ok);
|
assert!(replacement.ok);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sink_guard.configured_sink(),
|
sink_guard.configured_sinks(),
|
||||||
Some(crate::GameCreatorManifestInvalidationEventSink {
|
vec![
|
||||||
|
crate::GameCreatorManifestInvalidationEventSink {
|
||||||
|
port: 31_318,
|
||||||
|
token: "b".repeat(64),
|
||||||
|
},
|
||||||
|
crate::GameCreatorManifestInvalidationEventSink {
|
||||||
port: 31_319,
|
port: 31_319,
|
||||||
token: "c".repeat(64),
|
token: "c".repeat(64),
|
||||||
})
|
},
|
||||||
|
],
|
||||||
|
"第二个窗口 attach 必须让两个接收端同时保留"
|
||||||
);
|
);
|
||||||
|
|
||||||
let stale_replay = handle_external_agent_runner_request(
|
let stale_replay = handle_external_agent_runner_request(
|
||||||
@@ -1426,7 +1554,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
params: ExternalAgentRunnerRequestParams {
|
params: ExternalAgentRunnerRequestParams {
|
||||||
event_sink_port: Some(31_318),
|
event_sink_port: Some(31_318),
|
||||||
event_sink_token: Some("b".repeat(64)),
|
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),
|
gui_owner_session_revision: Some(0),
|
||||||
..ExternalAgentRunnerRequestParams::default()
|
..ExternalAgentRunnerRequestParams::default()
|
||||||
},
|
},
|
||||||
@@ -1439,11 +1567,17 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u
|
|||||||
Some("platform-session-invalid")
|
Some("platform-session-invalid")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sink_guard.configured_sink(),
|
sink_guard.configured_sinks(),
|
||||||
Some(crate::GameCreatorManifestInvalidationEventSink {
|
vec![
|
||||||
|
crate::GameCreatorManifestInvalidationEventSink {
|
||||||
|
port: 31_318,
|
||||||
|
token: "b".repeat(64),
|
||||||
|
},
|
||||||
|
crate::GameCreatorManifestInvalidationEventSink {
|
||||||
port: 31_319,
|
port: 31_319,
|
||||||
token: "c".repeat(64),
|
token: "c".repeat(64),
|
||||||
}),
|
},
|
||||||
|
],
|
||||||
"旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端"
|
"旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -202,9 +202,13 @@ fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
|
|||||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested
|
GameCreatorGuiRunnerShutdownOutcome::NotRequested
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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
|
GameCreatorGuiRunnerShutdownOutcome::Requested
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(false)),
|
||||||
|
GameCreatorGuiRunnerShutdownOutcome::Retained
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || {
|
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || {
|
||||||
Err("private shutdown diagnostic".to_string())
|
Err("private shutdown diagnostic".to_string())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "陶泥儿",
|
"productName": "陶泥儿",
|
||||||
"version": "0.1.45",
|
"version": "0.1.47",
|
||||||
"identifier": "world.genarrative.ai-game-creator",
|
"identifier": "world.genarrative.ai-game-creator",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
|
||||||
const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
|
||||||
const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock";
|
const GUI_PARTICIPANT_LOCK_FILE_NAME: &str = "agent-runner.gui-participant.lock";
|
||||||
|
|
||||||
struct TestDirectory(PathBuf);
|
struct TestDirectory(PathBuf);
|
||||||
|
|
||||||
@@ -50,8 +50,9 @@ fn open_locked_file(path: &Path) -> File {
|
|||||||
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||||
.open(path)
|
.open(path)
|
||||||
.expect("open isolated lock file");
|
.expect("open isolated lock file");
|
||||||
|
// 模拟一个界面窗口:参与锁以共享锁持有,多个窗口可以同时持有。
|
||||||
// SAFETY: file owns a live descriptor and flock does not retain pointers.
|
// SAFETY: file owns a live descriptor and flock does not retain pointers.
|
||||||
assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0);
|
assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH) }, 0);
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ fn runner_binary() -> &'static str {
|
|||||||
#[test]
|
#[test]
|
||||||
fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() {
|
fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() {
|
||||||
let directory = TestDirectory::new("owner-lost-before-check");
|
let directory = TestDirectory::new("owner-lost-before-check");
|
||||||
let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME));
|
let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME));
|
||||||
let script =
|
let script =
|
||||||
"kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required";
|
"kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required";
|
||||||
let mut child = Command::new("/bin/sh")
|
let mut child = Command::new("/bin/sh")
|
||||||
@@ -139,7 +140,7 @@ fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() {
|
fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() {
|
||||||
let directory = TestDirectory::new("owner-lost-after-start");
|
let directory = TestDirectory::new("owner-lost-after-start");
|
||||||
let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME));
|
let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME));
|
||||||
let mut child = Command::new(runner_binary())
|
let mut child = Command::new(runner_binary())
|
||||||
.arg("--agent-runner")
|
.arg("--agent-runner")
|
||||||
.arg("--config-dir")
|
.arg("--config-dir")
|
||||||
|
|||||||
@@ -38,11 +38,16 @@ export function useDirectActiveTurns({
|
|||||||
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||||
|
const retryTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
if (retryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(retryTimerRef.current);
|
||||||
|
retryTimerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -73,12 +78,12 @@ export function useDirectActiveTurns({
|
|||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
|
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
|
||||||
await new Promise((resolve) =>
|
await new Promise<void>((resolve) => {
|
||||||
window.setTimeout(
|
retryTimerRef.current = window.setTimeout(() => {
|
||||||
resolve,
|
retryTimerRef.current = null;
|
||||||
DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt,
|
resolve();
|
||||||
),
|
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user