修复运行时配置读取时 GameAgent 窗口卡死 #263

Closed
suzmii wants to merge 2 commits from fix/game-agent-config-read-lock into feat/agc-llm-router-official-chain
4 changed files with 118 additions and 20 deletions
@@ -1832,8 +1832,10 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks(
}
#[tauri::command]
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
pub(crate) async fn check_game_creator_llm_config() -> Result<GameCreatorLlmConfigStatus, String> {
tokio::task::spawn_blocking(check_game_creator_llm_config_from_config)
.await
.map_err(|error| format!("LLM 配置检查失败:{error}"))
}
#[tauri::command]
@@ -274,6 +274,7 @@ export function RuntimeConfigDialog({
const [appUpdateStatus, setAppUpdateStatus] = useState('');
const [appUpdateChecking, setAppUpdateChecking] = useState(false);
const runtimeConfigBusyRef = useRef(false);
const runtimeConfigDiagnosticRequestRef = useRef(0);
useEscapeToClose(onClose);
@@ -478,6 +479,25 @@ export function RuntimeConfigDialog({
}));
}
async function refreshEffectiveLlmConfigStatus() {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
const requestId = ++runtimeConfigDiagnosticRequestRef.current;
try {
const status = await invoke<GameCreatorLlmConfigStatus>(
'check_game_creator_llm_config',
);
if (requestId === runtimeConfigDiagnosticRequestRef.current) {
setEffectiveLlmConfigStatus(status);
}
} catch {
// The diagnostic is supplementary. Config read/save must remain usable
// when the command is unavailable or the Codex probe fails.
}
}
async function readRuntimeConfig() {
if (runtimeConfigBusyRef.current) {
return;
@@ -502,15 +522,7 @@ export function RuntimeConfigDialog({
);
setRuntimeConfigDraft(config);
setRuntimeConfigStatus(`已读取:${result.path}`);
try {
const status = await invoke<GameCreatorLlmConfigStatus>(
'check_game_creator_llm_config',
);
setEffectiveLlmConfigStatus(status);
} catch {
// Older test fixtures and non-Tauri previews may not expose the
// diagnostic command; the config read itself remains usable.
}
void refreshEffectiveLlmConfigStatus();
onLog?.('runtime_config.read');
} catch (error) {
setRuntimeConfigStatus(
@@ -559,15 +571,7 @@ export function RuntimeConfigDialog({
);
setRuntimeConfigDraft(savedConfig);
setRuntimeConfigStatus(`已保存:${result.path}`);
try {
const status = await invoke<GameCreatorLlmConfigStatus>(
'check_game_creator_llm_config',
);
setEffectiveLlmConfigStatus(status);
} catch {
// Keep the last safe status when the optional diagnostic refresh is
// unavailable in a preview or test fixture.
}
void refreshEffectiveLlmConfigStatus();
setRuntimeConfigToast({
tone: 'success',
message: '保存成功,新的运行时配置已生效',
@@ -757,6 +757,94 @@ export function registerRuntimeSettingsTests() {
false,
);
});
it('does not block runtime config actions while LLM diagnostics are pending', async () => {
let resolveRead:
| ((value: {
path: string;
config: {
agentMode: 'provider';
llm: {
apiKey: string;
baseUrl: string;
model: string;
apiKind: string;
stream: boolean;
webSearchEnabled: boolean;
requestTimeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
};
editorApi: { baseUrl: string; apiKey: string };
};
}) => void)
| undefined;
let resolveDiagnostic: ((value: unknown) => void) | undefined;
const invoke = vi.fn((command: string) => {
if (command === 'read_game_creator_app_config') {
return new Promise((resolve) => {
resolveRead = resolve as typeof resolveRead;
});
}
if (command === 'check_game_creator_llm_config') {
return new Promise((resolve) => {
resolveDiagnostic = resolve;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
expect(await screen.findByText('正在读取')).not.toBeNull();
await act(async () => {
resolveRead?.({
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'provider',
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
});
});
expect(await screen.findByText(/已读取:/)).not.toBeNull();
expect(screen.getByRole('button', { name: '读取' })).toHaveProperty(
'disabled',
false,
);
expect(screen.getByRole('button', { name: '保存' })).toHaveProperty(
'disabled',
false,
);
expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config');
await act(async () => {
resolveDiagnostic?.({
configured: true,
accountCredentialState: 'ready',
});
});
expect(await screen.findByText('账号权限已就绪')).not.toBeNull();
});
}
export function registerPublishedRuntimeSettingsTests() {
@@ -220,6 +220,10 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
- 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。
- 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
- LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model providerbase URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
- 配置面板交互:读取或保存配置只等待本地配置文件操作;
`check_game_creator_llm_config` 属于补充诊断,必须后台刷新并带有过期结果保护,
不得让 Codex CLI/app-server 能力探测阻塞配置内容展示或保存反馈;Tauri 命令本身也必须在阻塞线程池执行,
不得占用窗口事件线程。诊断失败、超时或账号未登录时,仅更新账号权限提示。
- 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
- remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。
- 资源与退出:app-server pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离并做有界 LRU;空 AppData Key 必须读取同一份有界 `auth.json` 字节来生成池指纹并桥接隔离登录态,继承的 `CODEX_API_KEY` 始终移除,node thread 也只淘汰 inactive LRU。Runner 正常、强制和 watchdog 退出都显式关池,Linux child 绑定 parent-death signal,防止强杀 Runner 后遗留带凭据孤儿进程。stdout NDJSON 与 stderr 无换行记录均有硬上限;stderr 原文不写入诊断,只记录固定分类、总字节数、SHA-256 和可取得的退出状态。