Compare commits

...

27 Commits

Author SHA1 Message Date
lhk229 9b6ed8a4cf 整理抠图测试导入并明确对外说明边界
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m13s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m52s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m41s
Project CI / Frontend tests (push) Successful in 5m30s
Project CI / Repository checks (push) Successful in 4m22s
Project CI / Native shell tests (push) Successful in 9m3s
Project CI / Backend tests (push) Successful in 9m19s
Project CI / AI game creator shell web tests (push) Successful in 5m12s
将主站与客户端抠图测试模块的导入统一放到模块顶部
明确 provider 选择与兜底策略仅保留在内部技术方案中
2026-09-17 00:54:28 +08:00
lhk229 16deb0ad61 补齐抠图参数透传与验收
修复flat自动识别门禁并严格校验模式和颜色

保持旧请求幂等指纹并区分客户端抠图意图

同步工具说明和Skill契约并补充定向测试

记录真实BgFilter验证及本地数据库阻塞的待验收项
2026-09-17 00:54:28 +08:00
lhk229 13f56e644d 补充抠图模式调用说明
说明complex语义分割与flat纯色背景抠图

明确纯色背景优先使用flat并移除内部实现术语
2026-09-17 00:54:28 +08:00
lhk229 e08b031761 调整抠图模式参数说明 2026-09-17 00:54:28 +08:00
lhk229 70ad794b28 扩展AGC抠图客户端参数
支持complex与flat模式及auto背景色

保留旧客户端参数调用兼容性
2026-09-17 00:54:28 +08:00
lhk229 ca761e093c 接通抠图模式到BgFilter队列
透传backgroundMode和screenColor到内部worker

保留auto及省略颜色的下游识别语义
2026-09-17 00:54:28 +08:00
lhk229 a17367112f 增加主站抠图参数归一化
缺省模式兼容为complex并校验颜色格式

将auto颜色原样保留给BgFilter
2026-09-17 00:54:28 +08:00
lhk229 d7a0c3cffd 扩展主站抠图请求契约
增加backgroundMode和screenColor可选字段

同步External v1 OpenAPI抠图模式说明
2026-09-17 00:54:28 +08:00
lhk229 fa64819b6e 冻结BgFilter抠图服务契约
记录flat与complex模式及auto背景色行为

补充服务器认证头和当前运行版本
2026-09-17 00:54:28 +08:00
lhk229 f748d36d21 补充AGC抠图模式透传方案
新增主站与BgFilter模式及背景色契约

补充AGC客户端同步任务与兼容性规则

记录联调验收和发布回滚边界
2026-09-17 00:54:28 +08:00
lhk229 79b153e3fa 修复Direct活动回合重试类型错误
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m53s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m5s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m23s
Project CI / Frontend tests (push) Failing after 4m42s
Project CI / Repository checks (push) Successful in 4m44s
Project CI / Backend tests (push) Successful in 9m27s
Project CI / Native shell tests (push) Successful in 9m17s
Project CI / AI game creator shell web tests (push) Failing after 5m2s
重试等待 Promise 显式声明为 void,修复 shell typecheck 与原生构建失败。

补充卸载后取消读取重试的回归测试。
2026-09-16 21:53:19 +08:00
lhk229 362edcc49d 修复前端测试偶发超时与异步清理
Direct 活动回合读取失败后的重试定时器随组件卸载清理,避免测试环境销毁后访问 window。

后台素材查询大量缩略图测试合并可见性触发,并补充独立超时预算。

创作首页活动图与资源卡预览调度重载用例分别补充独立超时预算。

清单快照测试补齐 Direct 活动回合 IPC mock,避免未预期命令进入重试。
2026-09-16 21:53:19 +08:00
kdletters 1e434e0cb5 文档记录 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m57s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m10s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m56s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 1m47s
Project CI / Repository checks (push) Successful in 3m34s
Project CI / Frontend tests (push) Successful in 4m25s
Project CI / Native shell tests (push) Successful in 7m56s
Project CI / AI game creator shell web tests (push) Successful in 4m32s
Project CI / Backend tests (push) Successful in 10m1s
- 在【开发运维】本地开发验证与生产运维新增「CI 宿主 CPU 上限」小节,写明 jenkins.service CPUQuota=1600% 与 gitea-stack runner cpus=12.0 的生效位置、核验命令和回滚方式
- 在 shared-memory/decision-log.md 补记该资源上限决策的背景、边界与验证证据
2026-09-16 21:17:30 +08:00
lhk229 cec971c438 修复局部修改工具的缺陷和偶发锁问题 (#386)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m49s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m16s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m14s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 6m33s
Project CI / Backend tests (push) Successful in 8m26s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/386
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-16 19:53:23 +08:00
lhk229 70157673b6 Merge branch 'master' into fix/ci
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m27s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m12s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m41s
Project CI / AI game creator shell Rust crates (push) Successful in 2m48s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m46s
Project CI / Frontend tests (push) Successful in 4m49s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Native shell tests (push) Successful in 6m41s
Project CI / Backend tests (push) Successful in 7m17s
Project CI / AI game creator shell web tests (push) Successful in 3m26s
2026-09-16 19:07:53 +08:00
kdletters 492e9e63af 合入 AGC 终止链路 abort 闪退修复
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m20s
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
codex_app_server 新增 spawn_codex_app_server_task,统一经 tauri::async_runtime::spawn 派发
终止链路、CodexThreadLease::drop、CodexTurnGuard::drop 三处不再在同步命令线程上 tokio::spawn
新增回归用例 codex_app_server_task_dispatch_needs_no_tokio_runtime_context
补记 tokio::spawn 导致 abort 闪退、cargo test 重写 generated 绑定两条踩坑
2026-09-16 18:58:19 +08:00
lhk229 57fae7037a 修复 Agent 异步测试等待
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m24s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m52s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m42s
Project CI / Frontend tests (pull_request) Successful in 4m28s
Project CI / Repository checks (pull_request) Successful in 3m39s
Project CI / Native shell tests (pull_request) Successful in 6m59s
Project CI / Backend tests (pull_request) Successful in 7m37s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m55s
移除不稳定的项目页文案前置

保留刷新 Agent 与拆解任务按钮就绪检查
2026-09-16 10:52:25 +00:00
kdletters 2bcfa10647 修复 AGC 客户端点「终止」整体闪退:同步命令线程改走 Tauri 全局异步 runtime
- codex_app_server 新增 spawn_codex_app_server_task,统一经 tauri::async_runtime::spawn 派发中断与收尾任务
- 终止链路 maybe_interrupt、CodexThreadLease::drop、CodexTurnGuard::drop 三处改走该入口,避免同步 Tauri 命令线程上 tokio::spawn panic 后跨 IPC 回调边界 abort
- 新增回归用例 codex_app_server_task_dispatch_needs_no_tokio_runtime_context,把派发入口钉在没有 tokio 上下文的线程上
- 同步 agc-skills manifest 指纹,修复 master 指纹回退导致的 skill-pack:check 失败(本地打包前置门禁)
- 记录两条踩坑:同步命令线程 tokio::spawn 导致客户端 abort 闪退、cargo test 重写 generated/*.ts 后前端 typecheck 必红
2026-09-16 18:40:14 +08:00
kdletters e777236817 补齐 decision-log 两处标题前缺失的空行
恢复引言引用块、2026-09-16 图标图集条目之前的空行分隔,与文件既有排版一致
仅新增两行空行,不改动任何决策条目内容
2026-09-16 18:35:52 +08:00
lhk229 d11c74212d Merge pull request '修复 AGC skill 包与 Agent 测试' (#387) from fix/ci into master
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m15s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m16s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m38s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m45s
Project CI / AI game creator shell Rust crates (push) Successful in 2m28s
Project CI / Frontend tests (push) Failing after 3m36s
Project CI / Repository checks (push) Failing after 3m40s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Backend tests (push) Successful in 7m47s
Project CI / AI game creator shell web tests (push) Failing after 3m44s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/387
2026-09-16 18:33:52 +08:00
lhk229 82b2f853e7 修复 AGC skill 包与 Agent 测试
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
同步工作流和试玩 skill 指纹

等待项目页面与拆解任务按钮就绪
2026-09-16 10:31:57 +00:00
kdletters 31f83a751a 三维请求自选技术栈,不再固定 Phaser
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m35s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 5m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 5m7s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 3m54s
Project CI / AI game creator shell Rust crates (push) Successful in 2m26s
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- DirectProject 系统提示新增三维请求合同:三维请求不受 Phaser 4.2.1 约束,由 Codex 自选三维技术栈
- 工程合同的 Phaser 固定约束按二维/三维分层,点名引擎的不匹配澄清规则保持不变
- 新增三维意图识别、三维回合合同与首页三维提示;伪 3D、等轴与代码话题不触发
- 补定向用例与里程碑/实施计划文档,并在 decision-log 记录该决策
2026-09-16 18:04:44 +08:00
lhk229 a1cafde7e9 修复 AI 创作壳测试稳定性
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m35s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m48s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m59s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m37s
Project CI / AI game creator shell Rust crates (push) Successful in 2m42s
Project CI / Native shell tests (push) Failing after 2m47s
Project CI / Frontend tests (push) Failing after 3m29s
Project CI / AI game creator shell web tests (push) Failing after 49s
Project CI / Repository checks (push) Failing after 3m16s
Project CI / Backend tests (push) Successful in 7m7s
调整 Agent 项目打开测试的异步就绪等待

延长权限策略连续命令测试超时预算

同步 taonier-art-assets skill-pack 内容指纹

补充对应实施计划中的测试约定
2026-09-16 09:57:21 +00:00
kdletters 0594a90bdd 合入平台会话身份与凭据分离
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust crates (push) Successful in 3m1s
Project CI / Frontend tests (push) Failing after 4m24s
Project CI / Repository checks (push) Successful in 4m1s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / AI game creator shell web tests (push) Failing after 3m4s
Project CI / Backend tests (push) Successful in 8m20s
平台会话快照拆分身份代次与写入 revision,凭据续期轮换不再中断在途生成
AGC Runner 请求参数新增 platform_auth_revision,并按 revision 做单调写入判定
解决与多窗口共享 Runner 的冲突,保留 claim adopt/publish 与界面参与锁语义
同步刷新轮换竞争、appSurface 鉴权用例与客户端 API 测试
补录平台会话身份与凭据分离的实施计划、共享记忆与决策记录
2026-09-16 17:42:00 +08:00
kdletters aee862532c 删除已完成的多窗口里程碑临时文档
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m38s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m57s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m36s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m52s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m42s
Project CI / AI game creator shell Rust crates (push) Successful in 2m39s
Project CI / Repository checks (push) Successful in 3m58s
Project CI / Frontend tests (push) Successful in 5m53s
Project CI / Native shell tests (push) Successful in 6m28s
Project CI / Backend tests (push) Successful in 7m49s
Project CI / AI game creator shell web tests (push) Successful in 2m8s
- 按规范驱动开发工作流,里程碑与实施计划属临时产物,合并后删除
- 持久结论已回写 AGC 技术方案 2026-09-16 节、共享记忆决策记录与踩坑记录
2026-09-16 17:15:22 +08:00
kdletters 42be8ea060 发布 AGC 标准版 0.1.47
- 版本号同步到 0.1.47:package.json / package-lock.json / tauri.conf.json / Cargo.toml / Cargo.lock
- 构建脚本读取 OSS 清单(已有 0.1.46)后自动递增,本版打包产物为 陶泥儿_0.1.47_x64-setup.exe
- 本轮同时包含同 AppData 多窗口共享 Agent Runner 的修复
2026-09-16 17:15:15 +08:00
kdletters 737a2266b9 支持同 AppData 多窗口共享 Agent Runner
- owner 独占锁改为可多窗口同时持有的界面参与锁 agent-runner.gui-participant.lock
- Runner 启动检查、attach 门禁与 watchdog 改用参与锁存活判定,最后一个窗口退出才关停 Runner
- 窗口启动只采纳现有 owner claim,登录/refresh/退出/换号才发布新 epoch claim
- 同 claim 重复 attach 改为幂等空操作,不再清空 Runner 平台登录态
- manifest 失效与 Runtime update relay 接收端改为按 token 去重的注册表并广播,失败只淘汰该接收端
- GUI 退出先释放参与锁,仍有其它窗口时保留 Runner 并记录 retained_for_other_windows
- Runner 启动失败时按最新 endpoint 复用一次,避免两窗口同时冷启动被实例锁竞争误报
- 更新 runner 定向测试、GUI 生命周期集成测试与自检用例
- 同步 AGC 技术方案、决策记录、踩坑记录与多窗口里程碑/实施计划
2026-09-16 16:56:21 +08:00
52 changed files with 2277 additions and 305 deletions
@@ -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 -1
View File
@@ -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
View File
@@ -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}}},
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. `agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{ {
"schemaVersion": "agc-skill-pack.v1", "schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.16", "version": "2026-08-26.17",
"skills": [ "skills": [
{ {
"name": "agc-game-production-workflow", "name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml", "agents/openai.yaml",
"references/projection-contract.md" "references/projection-contract.md"
], ],
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" "sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
} }
] ]
} }
@@ -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_EXITabort——现场就是"点终止,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` 在那里 panicpanic 跨不过 IPC 回调边界就把整个
/// 进程 abort0xC0000409"点终止就闪退")。这条用例把派发入口钉在没有 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
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async { let result = async {
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?; super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?; enforce_project_permission_policy(&state.root, "asset.register")?;
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
@@ -1896,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
"assetName", "assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?; )?;
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
let manifest = read_existing_manifest_for_project(&state.root)?; let manifest = read_existing_manifest_for_project(&state.root)?;
let source_asset = manifest let source_asset = manifest
.assets .assets
@@ -1920,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.map_err(|_| "创建抠图服务连接失败".to_string())?; .map_err(|_| "创建抠图服务连接失败".to_string())?;
let context = let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?; prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let fingerprint = format!("{}\0{}", source_asset_id, asset_name); let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals"; let route = "/api/external/v1/editor/images/background-removals";
let response = crate::http_client::with_agc_main_site_marker( let mut request_body = json!({
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id, "sourceImageSrc": source_resource_id,
"projectId": manifest.project_id, "projectId": manifest.project_id,
"assetKind": source_asset.kind, "assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id, "assetFolderId": context.asset_folder_id,
"assetLabel": asset_name, "assetLabel": asset_name,
"sourceResourceId": source_resource_id, "sourceResourceId": source_resource_id,
})), });
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&request_body),
) )
.send() .send()
.await .await
@@ -1972,6 +1986,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
} }
} }
fn background_removal_request_fingerprint(
source: &str,
name: &str,
mode: Option<&str>,
color: Option<&str>,
) -> String {
let mode = mode.unwrap_or("complex");
if mode == "complex" && color.is_none() {
format!("{source}\0{name}")
} else {
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
}
}
fn bridge_safe_queue_state(value: Value) -> Value { fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object(); let object = value.as_object();
json!({ json!({
@@ -2703,6 +2731,29 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", None, None),
legacy
);
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
legacy
);
let mut identities = std::collections::HashSet::new();
identities.insert(legacy.to_string());
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
let id =
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
assert_eq!(
id,
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
);
assert!(identities.insert(id));
}
}
use super::*; use super::*;
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}), }),
json!({ json!({
"name": "agc_remove_background", "name": "agc_remove_background",
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", "description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
},
"backgroundMode": {
"type": "string",
"enum": ["complex", "flat"],
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
},
"screenColor": {
"type": "string",
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
} }
}, },
"required": ["sourceLocalAssetId", "assetName"], "required": ["sourceLocalAssetId", "assetName"],
@@ -881,14 +891,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
} }
} }
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; validate_tool_object_fields(
arguments,
&[
"sourceLocalAssetId",
"assetName",
"backgroundMode",
"screenColor",
],
)?;
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
bounded_tool_string( bounded_tool_string(
arguments, arguments,
"assetName", "assetName",
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
)?; )?;
if let Some(mode) = arguments.get("backgroundMode") {
let mode = mode
.as_str()
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
if mode != "complex" && mode != "flat" {
return Err("backgroundMode 必须是 complex 或 flat".to_string());
}
}
if let Some(color) = arguments.get("screenColor") {
let color = color
.as_str()
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
let valid_hex = color.len() == 7
&& color.starts_with('#')
&& color[1..]
.chars()
.all(|character| character.is_ascii_hexdigit());
if color != "auto" && !valid_hex {
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
}
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
return Err("complex 模式不能传 screenColor".to_string());
}
}
Ok(()) Ok(())
} }
@@ -1849,6 +1891,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn remove_background_arguments_enforce_mode_color_contract() {
for fields in [
json!({}),
json!({"backgroundMode":"complex"}),
json!({"backgroundMode":"flat"}),
json!({"backgroundMode":"flat","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_ok(),
"{fields}"
);
}
for fields in [
json!({"screenColor":"auto"}),
json!({"backgroundMode":"complex","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":""}),
json!({"backgroundMode":"flat","screenColor":" auto "}),
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
json!({"backgroundMode":"flat","screenColor":null}),
json!({"backgroundMode":"flat","screenColor":12}),
json!({"backgroundMode":""}),
json!({"backgroundMode":"FLAT"}),
json!({"backgroundMode":" flat "}),
json!({"backgroundMode":null}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_err(),
"{fields}"
);
}
}
#[cfg(all(windows, feature = "cocos-editor-execute"))] #[cfg(all(windows, feature = "cocos-editor-execute"))]
#[test] #[test]
fn builtin_mcp_process_probe() { fn builtin_mcp_process_probe() {
@@ -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 文件 IOclaim 由调用方按 `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,
&registered_config_dir, &registered_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 不能覆盖当前事件接收端"
); );

Some files were not shown because too many files have changed in this diff Show More