5550e24f83
新增 Codex app-server 与 CLI 节点执行模式并保留 Provider 回退 加固节点凭据隔离、终态未知回收、进程生命周期与持久恢复边界 修复资源画布等价刷新闪烁 为 Supervisor steer 增加 LLM 回复与条件中断 补齐配置界面、测试和技术文档
1646 lines
60 KiB
Rust
1646 lines
60 KiB
Rust
use super::{dispatch::*, endpoint::*, project_owner::*, protocol::*, state::*};
|
||
use crate::{
|
||
AgentRuntimeContextCompactionResult, GameCreatorManifestInvalidationEventSink,
|
||
GameCreatorMcpCatalog,
|
||
};
|
||
use serde_json::Value;
|
||
use sha2::{Digest as _, Sha256};
|
||
use std::ffi::OsString;
|
||
use std::fs;
|
||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Child, Command, Stdio};
|
||
use std::sync::{Mutex, OnceLock};
|
||
use std::thread;
|
||
use std::time::{Duration, Instant};
|
||
|
||
const AGENT_RUNNER_LOG_FILE_NAME: &str = "agent-runner.log";
|
||
const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024;
|
||
const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024;
|
||
const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||
|
||
#[derive(Default)]
|
||
pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState {
|
||
generation: u64,
|
||
registration: Option<ExternalAgentRunnerGuiOwnerRegistration>,
|
||
}
|
||
|
||
struct ExternalAgentRunnerGuiOwnerRegistration {
|
||
generation: u64,
|
||
config_dir: PathBuf,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
attached_boot_id: Option<String>,
|
||
}
|
||
|
||
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
|
||
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||
> = OnceLock::new();
|
||
|
||
fn external_agent_runner_gui_owner_attachment_state(
|
||
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE
|
||
.get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()))
|
||
}
|
||
|
||
pub(super) fn register_external_agent_runner_gui_owner_attachment(
|
||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||
config_dir: &Path,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
) {
|
||
let mut state = lock_unpoisoned(state);
|
||
state.generation = state.generation.wrapping_add(1);
|
||
let generation = state.generation;
|
||
state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration {
|
||
generation,
|
||
config_dir: config_dir.to_path_buf(),
|
||
params,
|
||
attached_boot_id: None,
|
||
});
|
||
}
|
||
|
||
pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with<F>(
|
||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||
config_dir: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
attach: F,
|
||
) -> Result<(), String>
|
||
where
|
||
F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>,
|
||
{
|
||
let Some((generation, params)) = ({
|
||
let state = lock_unpoisoned(state);
|
||
state.registration.as_ref().and_then(|registration| {
|
||
(registration.config_dir == config_dir
|
||
&& registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()))
|
||
.then(|| (registration.generation, registration.params.clone()))
|
||
})
|
||
}) else {
|
||
return Ok(());
|
||
};
|
||
|
||
attach(endpoint, params)?;
|
||
|
||
let mut state = lock_unpoisoned(state);
|
||
if let Some(registration) = state.registration.as_mut() {
|
||
if registration.generation == generation && registration.config_dir == config_dir {
|
||
registration.attached_boot_id = Some(endpoint.boot_id.clone());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn redact_url_queries(line: &str) -> String {
|
||
line.split_whitespace()
|
||
.map(|token| {
|
||
if (token.starts_with("http://") || token.starts_with("https://"))
|
||
&& token.contains('?')
|
||
{
|
||
let base = token.split_once('?').map(|(base, _)| base).unwrap_or(token);
|
||
format!("{base}?<query-redacted>")
|
||
} else {
|
||
token.to_string()
|
||
}
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
|
||
fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
|
||
let lowercase = line.to_ascii_lowercase();
|
||
if [
|
||
"authorization",
|
||
"bearer ",
|
||
"api_key",
|
||
"apikey",
|
||
"api key",
|
||
"x-api-key",
|
||
"token=",
|
||
"token:",
|
||
"credential",
|
||
"password",
|
||
"cookie",
|
||
"set-cookie",
|
||
"secret",
|
||
"access_token",
|
||
"refresh_token",
|
||
"\"token\"",
|
||
"'token'",
|
||
]
|
||
.iter()
|
||
.any(|marker| lowercase.contains(marker))
|
||
{
|
||
return "<sensitive runner output redacted>".to_string();
|
||
}
|
||
if lowercase.contains("panic") {
|
||
return "<runner panic details redacted>".to_string();
|
||
}
|
||
let safe_internal_detail =
|
||
lowercase.starts_with("agent.runner.failed:") || lowercase.starts_with("runner.");
|
||
if !safe_internal_detail {
|
||
let summary = if ["error", "failed", "failure", "失败", "错误", "异常"]
|
||
.iter()
|
||
.any(|marker| lowercase.contains(marker))
|
||
{
|
||
"<runner error details omitted>"
|
||
} else if ["warning", "warn:"]
|
||
.iter()
|
||
.any(|marker| lowercase.contains(marker))
|
||
{
|
||
"<runner warning details omitted>"
|
||
} else {
|
||
"<non-diagnostic runner output omitted>"
|
||
};
|
||
return summary.to_string();
|
||
}
|
||
crate::sanitize_diagnostic_message(&redact_url_queries(line), Some(config_dir))
|
||
.chars()
|
||
.take(AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS)
|
||
.collect()
|
||
}
|
||
|
||
fn read_bounded_agent_runner_line<R: BufRead>(
|
||
reader: &mut R,
|
||
) -> io::Result<Option<(String, bool)>> {
|
||
let mut content = Vec::new();
|
||
let mut truncated = false;
|
||
let mut saw_bytes = false;
|
||
loop {
|
||
let available = reader.fill_buf()?;
|
||
if available.is_empty() {
|
||
return if saw_bytes {
|
||
Ok(Some((
|
||
String::from_utf8_lossy(&content).into_owned(),
|
||
truncated,
|
||
)))
|
||
} else {
|
||
Ok(None)
|
||
};
|
||
}
|
||
saw_bytes = true;
|
||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||
let consumed = newline.map(|index| index + 1).unwrap_or(available.len());
|
||
let payload_len = newline.unwrap_or(available.len());
|
||
let remaining = AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES.saturating_sub(content.len());
|
||
let copied = payload_len.min(remaining);
|
||
content.extend_from_slice(&available[..copied]);
|
||
if copied < payload_len {
|
||
truncated = true;
|
||
}
|
||
reader.consume(consumed);
|
||
if newline.is_some() {
|
||
return Ok(Some((
|
||
String::from_utf8_lossy(&content).into_owned(),
|
||
truncated,
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn spawn_agent_runner_log_pump<R>(
|
||
stream: R,
|
||
stream_name: &'static str,
|
||
log_path: PathBuf,
|
||
config_dir: PathBuf,
|
||
) where
|
||
R: Read + Send + 'static,
|
||
{
|
||
let _ = thread::Builder::new()
|
||
.name(format!("agent-runner-{stream_name}-log"))
|
||
.spawn(move || {
|
||
let mut reader = BufReader::new(stream);
|
||
loop {
|
||
match read_bounded_agent_runner_line(&mut reader) {
|
||
Ok(None) => break,
|
||
Ok(Some((line, truncated))) => {
|
||
let line = sanitize_agent_runner_output(line.trim(), &config_dir);
|
||
let _ = crate::append_bounded_diagnostic_line(
|
||
&log_path,
|
||
&format!(
|
||
"runner.{stream_name} truncated={} {line}",
|
||
if truncated { "true" } else { "false" }
|
||
),
|
||
);
|
||
}
|
||
Err(_) => {
|
||
let _ = crate::append_bounded_diagnostic_line(
|
||
&log_path,
|
||
&format!("runner.{stream_name}.read-failed details=redacted"),
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
pub(super) struct LaunchedExternalAgentRunner {
|
||
child: Child,
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
runner_job: crate::WindowsKillOnCloseJob,
|
||
}
|
||
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
fn terminate_failed_external_agent_runner_launch(child: &mut Child, error: String) -> String {
|
||
let kill_error = child.kill().err();
|
||
let wait_error = child.wait().err();
|
||
match (kill_error, wait_error) {
|
||
(None, None) => error,
|
||
(kill_error, wait_error) => format!(
|
||
"{error};清理启动失败的 Agent Runner 时出错:kill={},wait={}",
|
||
kill_error
|
||
.map(|error| error.to_string())
|
||
.unwrap_or_else(|| "ok".to_string()),
|
||
wait_error
|
||
.map(|error| error.to_string())
|
||
.unwrap_or_else(|| "ok".to_string())
|
||
),
|
||
}
|
||
}
|
||
|
||
pub(super) fn launch_external_agent_runner(
|
||
config_dir: &Path,
|
||
) -> Result<LaunchedExternalAgentRunner, String> {
|
||
let executable = std::env::current_exe()
|
||
.map_err(|error| format!("读取 Agent Runner 当前二进制失败:{error}"))?;
|
||
let runner_log_path = config_dir.join(AGENT_RUNNER_LOG_FILE_NAME);
|
||
let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.begin");
|
||
let mut command = Command::new(executable);
|
||
let gui_owner_required =
|
||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT.load(std::sync::atomic::Ordering::Acquire);
|
||
command
|
||
.args(external_agent_runner_launch_arguments(
|
||
config_dir,
|
||
gui_owner_required,
|
||
))
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped());
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::process::CommandExt;
|
||
|
||
// SAFETY: the closure only calls the async-signal-safe setsid syscall before exec.
|
||
unsafe {
|
||
command.pre_exec(|| {
|
||
if libc::setsid() == -1 {
|
||
Err(io::Error::last_os_error())
|
||
} else {
|
||
Ok(())
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
{
|
||
#[cfg(all(not(debug_assertions), feature = "game-chat-release"))]
|
||
crate::configure_windows_suspended_background_std_command(&mut command, true);
|
||
#[cfg(not(all(not(debug_assertions), feature = "game-chat-release")))]
|
||
crate::configure_windows_background_std_command(&mut command, true);
|
||
}
|
||
|
||
let mut child = command
|
||
.spawn()
|
||
.map_err(|error| format!("启动外部 Agent Runner 失败:{error}"))?;
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
let runner_job = match crate::WindowsKillOnCloseJob::assign_runner(&child) {
|
||
Ok(job) => job,
|
||
Err(error) => {
|
||
return Err(terminate_failed_external_agent_runner_launch(
|
||
&mut child, error,
|
||
));
|
||
}
|
||
};
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
if let Err(error) = runner_job.resume_suspended_runner(&child) {
|
||
drop(runner_job);
|
||
return Err(terminate_failed_external_agent_runner_launch(
|
||
&mut child, error,
|
||
));
|
||
}
|
||
if let Some(stdout) = child.stdout.take() {
|
||
spawn_agent_runner_log_pump(
|
||
stdout,
|
||
"stdout",
|
||
runner_log_path.clone(),
|
||
config_dir.to_path_buf(),
|
||
);
|
||
}
|
||
if let Some(stderr) = child.stderr.take() {
|
||
spawn_agent_runner_log_pump(
|
||
stderr,
|
||
"stderr",
|
||
runner_log_path.clone(),
|
||
config_dir.to_path_buf(),
|
||
);
|
||
}
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
let _ = crate::append_bounded_diagnostic_line(
|
||
&runner_log_path,
|
||
"runner.launch.job.assigned-and-resumed",
|
||
);
|
||
let _ = crate::append_bounded_diagnostic_line(&runner_log_path, "runner.launch.spawned");
|
||
Ok(LaunchedExternalAgentRunner {
|
||
child,
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
runner_job,
|
||
})
|
||
}
|
||
|
||
pub(super) fn external_agent_runner_launch_arguments(
|
||
config_dir: &Path,
|
||
gui_owner_required: bool,
|
||
) -> Vec<OsString> {
|
||
let mut arguments = vec![
|
||
OsString::from("--agent-runner"),
|
||
OsString::from("--config-dir"),
|
||
config_dir.as_os_str().to_os_string(),
|
||
];
|
||
if gui_owner_required {
|
||
arguments.push(OsString::from("--gui-owner-required"));
|
||
}
|
||
arguments
|
||
}
|
||
|
||
pub(super) fn send_external_agent_runner_request_with_protocol_and_id(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
protocol_version: u32,
|
||
request_id: String,
|
||
method: &str,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
) -> Result<Value, String> {
|
||
send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
|
||
endpoint,
|
||
protocol_version,
|
||
request_id,
|
||
method,
|
||
params,
|
||
EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT,
|
||
external_agent_runner_client_read_timeout(method),
|
||
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT,
|
||
)
|
||
}
|
||
|
||
fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
protocol_version: u32,
|
||
request_id: String,
|
||
method: &str,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
connect_timeout: Duration,
|
||
read_timeout: Duration,
|
||
write_timeout: Duration,
|
||
) -> Result<Value, String> {
|
||
if endpoint.protocol_version != protocol_version {
|
||
return Err("Agent Runner endpoint 协议版本不兼容".to_string());
|
||
}
|
||
let request = ExternalAgentRunnerRequest {
|
||
protocol_version,
|
||
request_id: request_id.clone(),
|
||
token: endpoint.token.clone(),
|
||
method: method.to_string(),
|
||
params,
|
||
};
|
||
let payload =
|
||
serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?;
|
||
let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into();
|
||
let mut stream = TcpStream::connect_timeout(&address, connect_timeout)
|
||
.map_err(|error| format!("连接 Agent Runner 失败:{error}"))?;
|
||
stream
|
||
.set_read_timeout(Some(read_timeout))
|
||
.and_then(|_| stream.set_write_timeout(Some(write_timeout)))
|
||
.map_err(|error| format!("配置 Agent Runner 客户端超时失败:{error}"))?;
|
||
write_external_agent_runner_frame(&mut stream, &payload)
|
||
.map_err(|error| format!("写入 Agent Runner 请求失败:{error}"))?;
|
||
stream
|
||
.flush()
|
||
.map_err(|error| format!("刷新 Agent Runner 请求失败:{error}"))?;
|
||
let response_payload = read_external_agent_runner_frame(&mut stream)
|
||
.map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?;
|
||
let response = serde_json::from_slice::<ExternalAgentRunnerResponse>(&response_payload)
|
||
.map_err(|_| "解析 Agent Runner 响应失败".to_string())?;
|
||
if response.protocol_version != protocol_version {
|
||
return Err("Agent Runner 响应协议版本不兼容".to_string());
|
||
}
|
||
if response.request_id != request_id {
|
||
return Err("Agent Runner 响应 requestId 不匹配".to_string());
|
||
}
|
||
if response.ok {
|
||
return Ok(response.result.unwrap_or(Value::Null));
|
||
}
|
||
let error = response.error.unwrap_or(ExternalAgentRunnerProtocolError {
|
||
code: "runner-error".to_string(),
|
||
message: "Agent Runner 请求失败".to_string(),
|
||
});
|
||
Err(redact_runner_secret(
|
||
&format!("{}: {}", error.code, error.message),
|
||
&endpoint.token,
|
||
))
|
||
}
|
||
|
||
pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration {
|
||
match method {
|
||
"runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT,
|
||
"mcp.status" => EXTERNAL_AGENT_RUNNER_MCP_STATUS_IO_TIMEOUT,
|
||
_ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT,
|
||
}
|
||
}
|
||
|
||
pub(super) fn send_external_agent_runner_request_with_id(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
request_id: String,
|
||
method: &str,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
) -> Result<Value, String> {
|
||
send_external_agent_runner_request_with_protocol_and_id(
|
||
endpoint,
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id,
|
||
method,
|
||
params,
|
||
)
|
||
}
|
||
|
||
pub(super) fn send_external_agent_runner_request(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
method: &str,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
) -> Result<Value, String> {
|
||
let request_id = random_identifier(b"genarrative-agent-runner-request-id")?;
|
||
send_external_agent_runner_request_with_id(endpoint, request_id, method, params)
|
||
}
|
||
|
||
pub(super) fn ping_external_agent_runner(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
) -> Result<(), String> {
|
||
send_external_agent_runner_request(
|
||
endpoint,
|
||
"runner.ping",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(super) fn retire_incompatible_external_agent_runner(
|
||
endpoint_path: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
allow_force_busy_migration: bool,
|
||
) -> Result<(), String> {
|
||
if !request_external_agent_runner_shutdown_if_idle_at(endpoint_path, endpoint)? {
|
||
if !allow_force_busy_migration {
|
||
return Err(
|
||
"Agent Runner 版本与当前客户端不一致,但旧 Runner 仍有任务,暂不能重启".to_string(),
|
||
);
|
||
}
|
||
force_terminate_external_agent_runner_and_cleanup(endpoint_path, endpoint, true)
|
||
.map_err(|error| format!("旧 Agent Runner busy 且安全迁移失败:{error}"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(super) fn verify_external_agent_runner_ping_identity(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
) -> Result<(), String> {
|
||
let result = send_external_agent_runner_request_with_protocol_and_id(
|
||
endpoint,
|
||
endpoint.protocol_version,
|
||
random_identifier(b"genarrative-agent-runner-identity-ping-id")?,
|
||
"runner.ping",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
)?;
|
||
if result.get("pid").and_then(Value::as_u64) != Some(endpoint.pid as u64)
|
||
|| result.get("bootId").and_then(Value::as_str) != Some(endpoint.boot_id.as_str())
|
||
{
|
||
return Err("Agent Runner ping 身份与 endpoint 不匹配".to_string());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn request_external_agent_runner_shutdown_if_idle_at(
|
||
endpoint_path: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
) -> Result<bool, String> {
|
||
let request_id = random_identifier(b"genarrative-agent-runner-upgrade-request-id")?;
|
||
let result = send_external_agent_runner_request_with_protocol_and_id(
|
||
endpoint,
|
||
endpoint.protocol_version,
|
||
request_id,
|
||
"runner.shutdown_if_idle",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
)?;
|
||
let idle = result
|
||
.get("idle")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner shutdown_if_idle 响应缺少 idle".to_string())?;
|
||
if !idle {
|
||
return Ok(false);
|
||
}
|
||
|
||
wait_for_external_agent_runner_boot_exit(
|
||
endpoint_path,
|
||
endpoint,
|
||
EXTERNAL_AGENT_RUNNER_START_TIMEOUT,
|
||
"旧 Agent Runner 未在版本切换期限内退出",
|
||
)?;
|
||
Ok(true)
|
||
}
|
||
|
||
fn wait_for_external_agent_runner_boot_exit(
|
||
endpoint_path: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
timeout: Duration,
|
||
timeout_error: &str,
|
||
) -> Result<(), String> {
|
||
let deadline = Instant::now() + timeout;
|
||
let lock_path = endpoint_path
|
||
.parent()
|
||
.map(external_agent_runner_lock_path)
|
||
.ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?;
|
||
loop {
|
||
match read_external_agent_runner_endpoint(endpoint_path) {
|
||
Ok(current) if current.boot_id == endpoint.boot_id => {}
|
||
Ok(_) => return Ok(()),
|
||
Err(_) => {
|
||
if let Some(lock) =
|
||
try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")?
|
||
{
|
||
drop(lock);
|
||
return Ok(());
|
||
}
|
||
}
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err(timeout_error.to_string());
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
fn read_external_agent_runner_endpoint_for_shutdown(
|
||
config_dir: &Path,
|
||
) -> Result<Option<(PathBuf, ExternalAgentRunnerEndpoint)>, String> {
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let lock_path = external_agent_runner_lock_path(config_dir);
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
|
||
loop {
|
||
match fs::symlink_metadata(&endpoint_path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
return Err("Agent Runner endpoint 不允许符号链接".to_string());
|
||
}
|
||
Ok(_) => {
|
||
let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?;
|
||
return Ok(Some((endpoint_path, endpoint)));
|
||
}
|
||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||
if let Some(lock) =
|
||
try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")?
|
||
{
|
||
drop(lock);
|
||
return Ok(None);
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err(
|
||
"Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string()
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 Agent Runner endpoint 元数据失败:{}: {error}",
|
||
endpoint_path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(super) fn shutdown_external_agent_runner_if_idle_at(config_dir: &Path) -> Result<bool, String> {
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let lock_path = external_agent_runner_lock_path(config_dir);
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
|
||
loop {
|
||
match fs::symlink_metadata(&endpoint_path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
return Err("Agent Runner endpoint 不允许符号链接".to_string());
|
||
}
|
||
Ok(_) => break,
|
||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||
if let Some(lock) =
|
||
try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")?
|
||
{
|
||
drop(lock);
|
||
return Ok(true);
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err(
|
||
"Agent Runner 启动锁仍被占用,但 endpoint 未在期限内就绪".to_string()
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 Agent Runner endpoint 元数据失败:{}: {error}",
|
||
endpoint_path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?;
|
||
request_external_agent_runner_shutdown_if_idle_at(&endpoint_path, &endpoint)
|
||
}
|
||
|
||
pub(crate) fn shutdown_external_agent_runner_if_idle() -> Result<bool, String> {
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||
shutdown_external_agent_runner_if_idle_at(&config_dir)
|
||
}
|
||
|
||
pub(super) fn shutdown_external_agent_runner_at(config_dir: &Path) -> Result<(), String> {
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let lock_path = external_agent_runner_lock_path(config_dir);
|
||
let endpoint_deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT;
|
||
loop {
|
||
match fs::symlink_metadata(&endpoint_path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
return Err("Agent Runner endpoint 不允许符号链接".to_string());
|
||
}
|
||
Ok(_) => break,
|
||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||
if let Some(lock) =
|
||
try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")?
|
||
{
|
||
drop(lock);
|
||
return Ok(());
|
||
}
|
||
if Instant::now() >= endpoint_deadline {
|
||
return Err(
|
||
"Agent Runner 实例锁仍被占用,但 endpoint 未在 GUI 关闭期限内出现"
|
||
.to_string(),
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(25));
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 Agent Runner endpoint 元数据失败:{}: {error}",
|
||
endpoint_path.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let endpoint = read_external_agent_runner_endpoint(&endpoint_path)?;
|
||
let graceful_shutdown = send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
|
||
&endpoint,
|
||
endpoint.protocol_version,
|
||
random_identifier(b"genarrative-agent-runner-gui-shutdown-id")?,
|
||
"runner.shutdown",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_CONNECT_TIMEOUT,
|
||
EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT,
|
||
EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT,
|
||
)
|
||
.and_then(|result| {
|
||
result
|
||
.get("willShutdown")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false)
|
||
.then_some(())
|
||
.ok_or_else(|| "Agent Runner shutdown 响应未确认退出".to_string())
|
||
});
|
||
match graceful_shutdown {
|
||
Ok(()) => {
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT;
|
||
loop {
|
||
match read_external_agent_runner_endpoint(&endpoint_path) {
|
||
Ok(current) if current.boot_id == endpoint.boot_id => {}
|
||
Ok(_) => return Ok(()),
|
||
Err(_) => {
|
||
if let Some(lock) = try_open_external_agent_runner_lock(
|
||
&lock_path,
|
||
"Agent Runner 单实例锁",
|
||
)? {
|
||
drop(lock);
|
||
return Ok(());
|
||
}
|
||
}
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return force_terminate_external_agent_runner_and_cleanup(
|
||
&endpoint_path,
|
||
&endpoint,
|
||
false,
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(25));
|
||
}
|
||
}
|
||
Err(graceful_error) => {
|
||
force_terminate_external_agent_runner_and_cleanup(&endpoint_path, &endpoint, true)
|
||
.map_err(|force_error| {
|
||
format!("{graceful_error};强制终止 Agent Runner 失败:{force_error}")
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn force_terminate_external_agent_runner_and_cleanup(
|
||
endpoint_path: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
allow_verified_legacy_identity: bool,
|
||
) -> Result<(), String> {
|
||
force_terminate_external_agent_runner_process(endpoint, allow_verified_legacy_identity)?;
|
||
let config_dir = endpoint_path
|
||
.parent()
|
||
.ok_or_else(|| "Agent Runner endpoint 缺少 AppData 父目录".to_string())?;
|
||
let lock_path = external_agent_runner_lock_path(config_dir);
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE;
|
||
loop {
|
||
if let Some(lock) =
|
||
try_open_external_agent_runner_lock(&lock_path, "Agent Runner 单实例锁")?
|
||
{
|
||
if read_external_agent_runner_endpoint(endpoint_path)
|
||
.ok()
|
||
.is_some_and(|current| current.boot_id == endpoint.boot_id)
|
||
{
|
||
fs::remove_file(endpoint_path)
|
||
.map_err(|error| format!("清理已终止 Agent Runner endpoint 失败:{error}"))?;
|
||
}
|
||
drop(lock);
|
||
return Ok(());
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err("Agent Runner 已终止但实例锁未在期限内释放".to_string());
|
||
}
|
||
thread::sleep(Duration::from_millis(25));
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
struct ExternalAgentRunnerPidFd(i32);
|
||
|
||
#[cfg(target_os = "linux")]
|
||
impl Drop for ExternalAgentRunnerPidFd {
|
||
fn drop(&mut self) {
|
||
// SAFETY: self.0 is an owned pidfd returned by pidfd_open.
|
||
unsafe { libc::close(self.0) };
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn open_external_agent_runner_pidfd(pid: i32) -> Result<Option<ExternalAgentRunnerPidFd>, String> {
|
||
// SAFETY: pidfd_open receives a range-checked pid and flags=0.
|
||
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
|
||
if fd < 0 {
|
||
let error = io::Error::last_os_error();
|
||
if error.raw_os_error() == Some(libc::ESRCH) {
|
||
return Ok(None);
|
||
}
|
||
return Err(format!("打开 Agent Runner pidfd 失败:{error}"));
|
||
}
|
||
Ok(Some(ExternalAgentRunnerPidFd(fd as i32)))
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn signal_external_agent_runner_pidfd(
|
||
pidfd: &ExternalAgentRunnerPidFd,
|
||
signal: i32,
|
||
) -> Result<bool, String> {
|
||
// SAFETY: pidfd is owned and live; null siginfo with flags=0 matches pidfd_send_signal.
|
||
let result = unsafe {
|
||
libc::syscall(
|
||
libc::SYS_pidfd_send_signal,
|
||
pidfd.0,
|
||
signal,
|
||
std::ptr::null::<libc::siginfo_t>(),
|
||
0,
|
||
)
|
||
};
|
||
if result == 0 {
|
||
return Ok(true);
|
||
}
|
||
let error = io::Error::last_os_error();
|
||
if error.raw_os_error() == Some(libc::ESRCH) {
|
||
Ok(false)
|
||
} else {
|
||
Err(format!("通过 pidfd 发送信号失败:{error}"))
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
fn force_terminate_external_agent_runner_process(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
allow_verified_legacy_identity: bool,
|
||
) -> Result<(), String> {
|
||
if endpoint.pid == std::process::id() {
|
||
return Err("拒绝终止当前 GUI 进程".to_string());
|
||
}
|
||
let pid = i32::try_from(endpoint.pid).map_err(|_| "Agent Runner pid 超出范围".to_string())?;
|
||
let Some(pidfd) = open_external_agent_runner_pidfd(pid)? else {
|
||
return Ok(());
|
||
};
|
||
if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() {
|
||
let actual_start_identity = external_agent_runner_process_start_identity(endpoint.pid)?
|
||
.ok_or_else(|| "当前平台未返回 Agent Runner 进程启动身份".to_string())?;
|
||
if actual_start_identity != expected_start_identity {
|
||
return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string());
|
||
}
|
||
} else if allow_verified_legacy_identity {
|
||
verify_external_agent_runner_ping_identity(endpoint)?;
|
||
} else {
|
||
return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string());
|
||
}
|
||
|
||
if !signal_external_agent_runner_pidfd(&pidfd, libc::SIGTERM)? {
|
||
return Ok(());
|
||
}
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE;
|
||
while signal_external_agent_runner_pidfd(&pidfd, 0)? && Instant::now() < deadline {
|
||
thread::sleep(Duration::from_millis(25));
|
||
}
|
||
if !signal_external_agent_runner_pidfd(&pidfd, 0)? {
|
||
return Ok(());
|
||
}
|
||
signal_external_agent_runner_pidfd(&pidfd, libc::SIGKILL)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn force_terminate_external_agent_runner_process(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
allow_verified_legacy_identity: bool,
|
||
) -> Result<(), String> {
|
||
use std::ffi::c_void;
|
||
|
||
#[repr(C)]
|
||
struct FileTime {
|
||
low_date_time: u32,
|
||
high_date_time: u32,
|
||
}
|
||
|
||
#[link(name = "kernel32")]
|
||
unsafe extern "system" {
|
||
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
|
||
fn GetProcessTimes(
|
||
process: *mut c_void,
|
||
creation_time: *mut FileTime,
|
||
exit_time: *mut FileTime,
|
||
kernel_time: *mut FileTime,
|
||
user_time: *mut FileTime,
|
||
) -> i32;
|
||
fn TerminateProcess(process: *mut c_void, exit_code: u32) -> i32;
|
||
fn WaitForSingleObject(handle: *mut c_void, milliseconds: u32) -> u32;
|
||
fn CloseHandle(handle: *mut c_void) -> i32;
|
||
}
|
||
|
||
const PROCESS_TERMINATE: u32 = 0x0001;
|
||
const SYNCHRONIZE: u32 = 0x0010_0000;
|
||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||
if endpoint.pid == std::process::id() {
|
||
return Err("拒绝终止当前 GUI 进程".to_string());
|
||
}
|
||
// SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below.
|
||
let process = unsafe {
|
||
OpenProcess(
|
||
PROCESS_TERMINATE | SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||
0,
|
||
endpoint.pid,
|
||
)
|
||
};
|
||
if process.is_null() {
|
||
let error = io::Error::last_os_error();
|
||
if error.raw_os_error() == Some(87) {
|
||
return Ok(());
|
||
}
|
||
return Err(format!("打开 Agent Runner 进程失败:{error}"));
|
||
}
|
||
let result = (|| {
|
||
if let Some(expected_start_identity) = endpoint.process_start_identity.as_deref() {
|
||
// SAFETY: FileTime is plain data filled by GetProcessTimes.
|
||
let mut creation = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
|
||
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
|
||
// SAFETY: process is live and all output pointers refer to writable FileTime values.
|
||
if unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }
|
||
== 0
|
||
{
|
||
return Err(format!(
|
||
"读取 Agent Runner 进程启动身份失败:{}",
|
||
io::Error::last_os_error()
|
||
));
|
||
}
|
||
let actual_start_identity = ((creation.high_date_time as u64) << 32
|
||
| creation.low_date_time as u64)
|
||
.to_string();
|
||
if actual_start_identity != expected_start_identity {
|
||
return Err("Agent Runner pid 已被其他进程复用,拒绝终止".to_string());
|
||
}
|
||
} else if allow_verified_legacy_identity {
|
||
verify_external_agent_runner_ping_identity(endpoint)?;
|
||
} else {
|
||
return Err("旧 Agent Runner endpoint 缺少进程启动身份,拒绝强制终止".to_string());
|
||
}
|
||
// SAFETY: the handle includes PROCESS_TERMINATE and its process start identity was verified.
|
||
if unsafe { TerminateProcess(process, 1) } == 0 {
|
||
return Err(format!(
|
||
"终止 Agent Runner 失败:{}",
|
||
io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: the handle includes SYNCHRONIZE and remains valid for this bounded wait.
|
||
let wait = unsafe { WaitForSingleObject(process, 1_000) };
|
||
if wait != 0 {
|
||
return Err(format!("等待 Agent Runner 退出失败:waitResult={wait}"));
|
||
}
|
||
Ok(())
|
||
})();
|
||
// SAFETY: process is an owned non-null handle returned by OpenProcess.
|
||
unsafe { CloseHandle(process) };
|
||
result
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn force_terminate_external_agent_runner_process(
|
||
_endpoint: &ExternalAgentRunnerEndpoint,
|
||
_allow_verified_legacy_identity: bool,
|
||
) -> Result<(), String> {
|
||
Err("macOS 不提供可绑定进程实例的安全强制终止句柄,拒绝按裸 pid 终止 Agent Runner".to_string())
|
||
}
|
||
|
||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||
fn force_terminate_external_agent_runner_process(
|
||
_endpoint: &ExternalAgentRunnerEndpoint,
|
||
_allow_verified_legacy_identity: bool,
|
||
) -> Result<(), String> {
|
||
Err("当前平台不支持核验并强制终止 Agent Runner".to_string())
|
||
}
|
||
|
||
pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> {
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||
shutdown_external_agent_runner_at(&config_dir)
|
||
}
|
||
|
||
pub(crate) fn attach_external_agent_runner_gui_owner(
|
||
event_sink: &GameCreatorManifestInvalidationEventSink,
|
||
) -> Result<(), String> {
|
||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
||
.store(true, std::sync::atomic::Ordering::Release);
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
external_agent_runner_gui_owner_attachment_state(),
|
||
&config_dir,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(event_sink.port),
|
||
event_sink_token: Some(event_sink.token.clone()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
);
|
||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||
}
|
||
|
||
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
||
result: &Value,
|
||
) -> Result<(), String> {
|
||
if result.get("attached").and_then(Value::as_bool) == Some(true)
|
||
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
|
||
{
|
||
Ok(())
|
||
} else {
|
||
Err("Agent Runner attach_gui_owner 响应未确认 owner 与事件接收端".to_string())
|
||
}
|
||
}
|
||
|
||
fn attach_external_agent_runner_gui_owner_at(
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
params: ExternalAgentRunnerRequestParams,
|
||
) -> Result<(), String> {
|
||
let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?;
|
||
validate_external_agent_runner_gui_owner_attachment_result(&result)
|
||
}
|
||
|
||
fn attach_registered_external_agent_runner_gui_owner_if_needed(
|
||
config_dir: &Path,
|
||
endpoint: &ExternalAgentRunnerEndpoint,
|
||
) -> Result<(), String> {
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
external_agent_runner_gui_owner_attachment_state(),
|
||
config_dir,
|
||
endpoint,
|
||
attach_external_agent_runner_gui_owner_at,
|
||
)
|
||
}
|
||
|
||
pub(super) fn shutdown_external_agent_runner_for_client_exit_at(
|
||
config_dir: &Path,
|
||
) -> Result<bool, String> {
|
||
let Some((endpoint_path, endpoint)) =
|
||
read_external_agent_runner_endpoint_for_shutdown(config_dir)?
|
||
else {
|
||
return Ok(true);
|
||
};
|
||
let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?;
|
||
let result = match send_external_agent_runner_request_with_protocol_and_id(
|
||
&endpoint,
|
||
endpoint.protocol_version,
|
||
request_id,
|
||
"runner.shutdown_for_client_exit",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
) {
|
||
Ok(result) => result,
|
||
Err(error) => {
|
||
return match read_external_agent_runner_endpoint(&endpoint_path) {
|
||
Ok(current) if current.boot_id == endpoint.boot_id => Err(error),
|
||
_ => Ok(true),
|
||
};
|
||
}
|
||
};
|
||
let accepted = result
|
||
.get("accepted")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?;
|
||
let busy = result
|
||
.get("busy")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 busy".to_string())?;
|
||
let will_shutdown = result
|
||
.get("willShutdown")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?;
|
||
match (accepted, busy, will_shutdown) {
|
||
(false, true, false) => return Ok(false),
|
||
(true, false, true) => {}
|
||
_ => return Err("Agent Runner shutdown_for_client_exit 响应状态不一致".to_string()),
|
||
}
|
||
wait_for_external_agent_runner_boot_exit(
|
||
&endpoint_path,
|
||
&endpoint,
|
||
AGENT_RUNNER_CLIENT_EXIT_TIMEOUT,
|
||
"Agent Runner 未在客户端退出期限内停止",
|
||
)?;
|
||
Ok(true)
|
||
}
|
||
|
||
pub(crate) fn shutdown_external_agent_runner_for_client_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);
|
||
};
|
||
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
|
||
}
|
||
|
||
pub(super) fn wait_for_external_agent_runner(
|
||
config_dir: &Path,
|
||
child: &mut Child,
|
||
executable_fingerprint: &str,
|
||
) -> Result<ExternalAgentRunnerEndpoint, String> {
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_START_TIMEOUT;
|
||
loop {
|
||
if let Some(endpoint) =
|
||
read_current_external_agent_runner_endpoint(&endpoint_path, executable_fingerprint)
|
||
{
|
||
if ping_external_agent_runner(&endpoint).is_ok() {
|
||
return Ok(endpoint);
|
||
}
|
||
}
|
||
if let Some(status) = child
|
||
.try_wait()
|
||
.map_err(|error| format!("检查外部 Agent Runner 子进程失败:{error}"))?
|
||
{
|
||
return Err(format!("外部 Agent Runner 在就绪前退出:{status}"));
|
||
}
|
||
if Instant::now() >= deadline {
|
||
return Err("外部 Agent Runner 未在启动期限内就绪".to_string());
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
pub(super) fn ensure_external_agent_runner(
|
||
config_dir: &Path,
|
||
) -> Result<ExternalAgentRunnerEndpoint, String> {
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?;
|
||
if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) {
|
||
match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) {
|
||
ExternalAgentRunnerReuseDecision::Reuse => {
|
||
if ping_external_agent_runner(&endpoint).is_ok() {
|
||
attach_registered_external_agent_runner_gui_owner_if_needed(
|
||
config_dir, &endpoint,
|
||
)?;
|
||
return Ok(endpoint);
|
||
}
|
||
}
|
||
ExternalAgentRunnerReuseDecision::Retire => {
|
||
let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id(
|
||
&endpoint,
|
||
endpoint.protocol_version,
|
||
random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?,
|
||
"runner.ping",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
);
|
||
if incompatible_ping.is_ok() {
|
||
retire_incompatible_external_agent_runner(
|
||
&endpoint_path,
|
||
&endpoint,
|
||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
||
.load(std::sync::atomic::Ordering::Acquire),
|
||
)?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut launched = launch_external_agent_runner(config_dir)?;
|
||
match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) {
|
||
Ok(endpoint) => {
|
||
thread::Builder::new()
|
||
.name("agent-runner-reaper".to_string())
|
||
.spawn(move || {
|
||
#[cfg(all(windows, not(debug_assertions), feature = "game-chat-release"))]
|
||
let _runner_job = launched.runner_job;
|
||
let _ = launched.child.wait();
|
||
})
|
||
.map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?;
|
||
attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?;
|
||
Ok(endpoint)
|
||
}
|
||
Err(error) => {
|
||
let _ = launched.child.kill();
|
||
let _ = launched.child.wait();
|
||
Err(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef<Path>) -> Result<(), String> {
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?;
|
||
set_external_agent_runner_config_dir(config_dir);
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn configure_external_agent_runner_read_only(
|
||
config_dir: impl AsRef<Path>,
|
||
) -> Result<(), String> {
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
let config_dir = inspect_external_agent_runner_config_dir(config_dir.as_ref())?;
|
||
set_external_agent_runner_config_dir(config_dir);
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn ensure_external_agent_runner_started() -> Result<(), String> {
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
|
||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn ensure_external_agent_runner_started_for_gui() -> Result<(), String> {
|
||
EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT
|
||
.store(true, std::sync::atomic::Ordering::Release);
|
||
ensure_external_agent_runner_started()
|
||
}
|
||
|
||
pub(crate) fn require_external_agent_runner_for_cli_runtime_write(
|
||
root: &Path,
|
||
) -> Result<(), String> {
|
||
require_external_agent_runner_configured_for_cli_runtime_write(root)?;
|
||
ensure_external_agent_runner_started()
|
||
}
|
||
|
||
pub(crate) fn require_external_agent_runner_configured_for_cli_runtime_write(
|
||
root: &Path,
|
||
) -> Result<(), String> {
|
||
if external_agent_runner_is_server_process() {
|
||
return Err("Agent Runner 进程不能作为普通 CLI 执行 Runtime 写命令".to_string());
|
||
}
|
||
let config_dir = external_agent_runner_config_dir().ok_or_else(|| {
|
||
"Agent Runtime 写命令必须显式传入 --config-dir <项目外 AppData 绝对路径>".to_string()
|
||
})?;
|
||
if !root.is_absolute() {
|
||
return Err("Agent Runtime 写命令的项目路径必须是绝对路径".to_string());
|
||
}
|
||
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, root)
|
||
}
|
||
|
||
pub(super) fn parse_external_agent_runner_notification_kind(
|
||
kind: &str,
|
||
) -> Result<(&'static str, Option<String>), String> {
|
||
match kind.trim() {
|
||
"wake_pending" | "runtime.wake_pending" => Ok(("runtime.wake_pending", None)),
|
||
"resume" | "runtime.resume" => Ok(("runtime.resume", None)),
|
||
"shutdown_if_idle" | "runner.shutdown_if_idle" => Ok(("runner.shutdown_if_idle", None)),
|
||
value => {
|
||
let agent = value
|
||
.strip_prefix("continue_action:")
|
||
.or_else(|| value.strip_prefix("runtime.continue_action:"))
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
match agent {
|
||
Some(agent) => Ok(("runtime.continue_action", Some(agent.to_string()))),
|
||
None => Err("未知 Agent Runner 通知类型".to_string()),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(super) fn send_external_agent_runner_runtime_request(
|
||
root: &Path,
|
||
method: &str,
|
||
agent: Option<&str>,
|
||
run_id: Option<&str>,
|
||
action_id: Option<&str>,
|
||
steer_id: Option<&str>,
|
||
) -> Result<Value, String> {
|
||
send_external_agent_runner_runtime_request_with_stable_identity(
|
||
root, method, agent, run_id, action_id, steer_id, None,
|
||
)
|
||
}
|
||
|
||
pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
|
||
root: &Path,
|
||
method: &str,
|
||
agent: Option<&str>,
|
||
run_id: Option<&str>,
|
||
action_id: Option<&str>,
|
||
steer_id: Option<&str>,
|
||
stable_identity: Option<&str>,
|
||
) -> Result<Value, String> {
|
||
let root = canonicalize_external_agent_runner_project_root(root)?;
|
||
let root = root
|
||
.to_str()
|
||
.ok_or_else(|| "通知 Agent Runner 的项目 root 必须是 UTF-8 路径".to_string())?;
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置".to_string())?;
|
||
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, Path::new(root))?;
|
||
|
||
// Establish liveness before the write request. The write itself is sent exactly once.
|
||
let endpoint = {
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
ensure_external_agent_runner(&config_dir)?
|
||
};
|
||
let params = ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string()),
|
||
agent: agent.map(str::to_string),
|
||
session_id: None,
|
||
run_id: run_id.map(str::to_string),
|
||
action_id: action_id.map(str::to_string),
|
||
steer_id: steer_id.map(str::to_string),
|
||
event_sink_port: None,
|
||
event_sink_token: None,
|
||
};
|
||
match stable_identity {
|
||
Some(stable_identity) => {
|
||
let identity = format!("{root}\n{method}\n{stable_identity}");
|
||
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
|
||
send_external_agent_runner_request_with_id(
|
||
&endpoint,
|
||
format!("runtime-parent-wake-{}", &fingerprint[..32]),
|
||
method,
|
||
params,
|
||
)
|
||
}
|
||
None => send_external_agent_runner_request(&endpoint, method, params),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn compact_external_agent_runner_context(
|
||
root: &Path,
|
||
agent: &str,
|
||
session_id: Option<&str>,
|
||
) -> Result<AgentRuntimeContextCompactionResult, String> {
|
||
let agent = agent.trim();
|
||
if agent.is_empty() {
|
||
return Err("手动压缩 Agent 上下文必须提供 agent".to_string());
|
||
}
|
||
let root = canonicalize_external_agent_runner_project_root(root)?;
|
||
let root_text = root
|
||
.to_str()
|
||
.ok_or_else(|| "通知 Agent Runner 的项目 root 必须是 UTF-8 路径".to_string())?;
|
||
let config_dir = external_agent_runner_config_dir()
|
||
.ok_or_else(|| "外部 Agent Runner 尚未配置".to_string())?;
|
||
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, &root)?;
|
||
let endpoint = {
|
||
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
|
||
ensure_external_agent_runner(&config_dir)?
|
||
};
|
||
let result = send_external_agent_runner_request(
|
||
&endpoint,
|
||
"runtime.compact",
|
||
ExternalAgentRunnerRequestParams {
|
||
root: Some(root_text.to_string()),
|
||
agent: Some(agent.to_string()),
|
||
session_id: session_id
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(str::to_string),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)?;
|
||
serde_json::from_value(result)
|
||
.map_err(|error| format!("解析 Agent Runner 上下文压缩结果失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn read_external_agent_runner_mcp_catalog(
|
||
root: &Path,
|
||
) -> Result<GameCreatorMcpCatalog, String> {
|
||
let result =
|
||
send_external_agent_runner_runtime_request(root, "mcp.status", None, None, None, None)?;
|
||
serde_json::from_value(result)
|
||
.map_err(|error| format!("解析 Agent Runner MCP catalog 失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), String> {
|
||
send_external_agent_runner_runtime_request(root, "runtime.wake_pending", None, None, None, None)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn wake_external_agent_runner_pending_for_run(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
loop_iteration: u32,
|
||
) -> Result<(), String> {
|
||
if agent.trim().is_empty() || run_id.trim().is_empty() {
|
||
return Err("父 run wake 必须同时提供 agent 和 runId".to_string());
|
||
}
|
||
let stable_identity = format!("{agent}\n{run_id}\n{loop_iteration}");
|
||
send_external_agent_runner_runtime_request_with_stable_identity(
|
||
root,
|
||
"runtime.wake_pending",
|
||
Some(agent),
|
||
Some(run_id),
|
||
None,
|
||
None,
|
||
Some(&stable_identity),
|
||
)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn resume_external_agent_runner(root: &Path) -> Result<(), String> {
|
||
send_external_agent_runner_runtime_request(root, "runtime.resume", None, None, None, None)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn continue_external_agent_runner_action(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
action_id: &str,
|
||
) -> Result<(), String> {
|
||
if [agent, run_id, action_id]
|
||
.into_iter()
|
||
.any(|value| value.trim().is_empty())
|
||
{
|
||
return Err("继续 Agent Runtime 动作必须同时提供 agent/runId/actionId".to_string());
|
||
}
|
||
send_external_agent_runner_runtime_request(
|
||
root,
|
||
"runtime.continue_action",
|
||
Some(agent),
|
||
Some(run_id),
|
||
Some(action_id),
|
||
None,
|
||
)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(crate) fn steer_external_agent_runner(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
steer_id: &str,
|
||
) -> Result<bool, String> {
|
||
if [agent, run_id, steer_id]
|
||
.into_iter()
|
||
.any(|value| value.trim().is_empty())
|
||
{
|
||
return Err("追加 Agent 指令必须同时提供 agent/runId/steerId".to_string());
|
||
}
|
||
let agent = agent.trim();
|
||
let run_id = run_id.trim();
|
||
let steer_id = steer_id.trim();
|
||
let result = send_external_agent_runner_runtime_request(
|
||
root,
|
||
"runtime.steer",
|
||
Some(agent),
|
||
Some(run_id),
|
||
None,
|
||
Some(steer_id),
|
||
)?;
|
||
parse_external_agent_runner_steer_result(&result)
|
||
}
|
||
|
||
pub(crate) fn interrupt_external_agent_runner_provider_for_steer_decision(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
steer_id: &str,
|
||
) -> Result<bool, String> {
|
||
if [agent, run_id, steer_id]
|
||
.into_iter()
|
||
.any(|value| value.trim().is_empty())
|
||
{
|
||
return Err("LLM steer 中断判定必须同时提供 agent/runId/steerId".to_string());
|
||
}
|
||
let result = send_external_agent_runner_runtime_request(
|
||
root,
|
||
"runtime.interrupt_for_steer_decision",
|
||
Some(agent.trim()),
|
||
Some(run_id.trim()),
|
||
None,
|
||
Some(steer_id.trim()),
|
||
)?;
|
||
parse_external_agent_runner_steer_result(&result)
|
||
}
|
||
|
||
pub(crate) fn pause_external_agent_runner(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
) -> Result<bool, String> {
|
||
if [agent, run_id]
|
||
.into_iter()
|
||
.any(|value| value.trim().is_empty())
|
||
{
|
||
return Err("暂停 Agent Goal 必须同时提供 agent/runId".to_string());
|
||
}
|
||
let result = send_external_agent_runner_runtime_request(
|
||
root,
|
||
"runtime.pause",
|
||
Some(agent.trim()),
|
||
Some(run_id.trim()),
|
||
None,
|
||
None,
|
||
)?;
|
||
result
|
||
.get("providerInterrupted")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner runtime.pause 响应缺少 providerInterrupted".to_string())
|
||
}
|
||
|
||
pub(crate) fn cancel_external_agent_runner_goal(
|
||
root: &Path,
|
||
agent: &str,
|
||
run_id: &str,
|
||
) -> Result<bool, String> {
|
||
if [agent, run_id]
|
||
.into_iter()
|
||
.any(|value| value.trim().is_empty())
|
||
{
|
||
return Err("清理 Agent Goal 必须同时提供 agent/runId".to_string());
|
||
}
|
||
let result = send_external_agent_runner_runtime_request(
|
||
root,
|
||
"runtime.cancel",
|
||
Some(agent.trim()),
|
||
Some(run_id.trim()),
|
||
None,
|
||
None,
|
||
)?;
|
||
result
|
||
.get("providerInterrupted")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner runtime.cancel 响应缺少 providerInterrupted".to_string())
|
||
}
|
||
|
||
pub(super) fn parse_external_agent_runner_steer_result(result: &Value) -> Result<bool, String> {
|
||
result
|
||
.get("providerInterrupted")
|
||
.and_then(Value::as_bool)
|
||
.ok_or_else(|| "Agent Runner runtime.steer 响应缺少 providerInterrupted".to_string())
|
||
}
|
||
|
||
pub(crate) fn notify_external_agent_runner(root: &Path, kind: &str) -> Result<(), String> {
|
||
let (method, agent) = parse_external_agent_runner_notification_kind(kind)?;
|
||
if method == "runtime.continue_action" {
|
||
return Err(
|
||
"continue_action 通知必须改用 typed helper 并绑定 agent/runId/actionId".to_string(),
|
||
);
|
||
}
|
||
send_external_agent_runner_runtime_request(root, method, agent.as_deref(), None, None, None)
|
||
.map(|_| ())
|
||
}
|
||
|
||
pub(super) fn read_external_agent_runner_status_at(
|
||
config_dir: Option<&Path>,
|
||
) -> ExternalAgentRunnerStatus {
|
||
let Some(config_dir) = config_dir else {
|
||
return ExternalAgentRunnerStatus::disabled();
|
||
};
|
||
let endpoint_path = external_agent_runner_endpoint_path(config_dir);
|
||
let endpoint = match read_external_agent_runner_endpoint(&endpoint_path) {
|
||
Ok(endpoint) => endpoint,
|
||
Err(error) => {
|
||
return ExternalAgentRunnerStatus {
|
||
enabled: true,
|
||
running: false,
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
pid: None,
|
||
boot_id: None,
|
||
port: None,
|
||
heartbeat_at: None,
|
||
error: Some(error),
|
||
};
|
||
}
|
||
};
|
||
let mut fallback = ExternalAgentRunnerStatus::from_endpoint(&endpoint, false);
|
||
if endpoint.protocol_version != EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION {
|
||
fallback.error = Some("Agent Runner endpoint 协议版本不兼容".to_string());
|
||
return fallback;
|
||
}
|
||
match send_external_agent_runner_request(
|
||
&endpoint,
|
||
"runner.status",
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
) {
|
||
Ok(value) => match serde_json::from_value::<ExternalAgentRunnerStatus>(value) {
|
||
Ok(mut status) => {
|
||
status.enabled = true;
|
||
status.error = None;
|
||
status
|
||
}
|
||
Err(_) => {
|
||
fallback.error = Some("解析 Agent Runner 状态失败".to_string());
|
||
fallback
|
||
}
|
||
},
|
||
Err(error) => {
|
||
fallback.error = Some(redact_runner_secret(&error, &endpoint.token));
|
||
fallback
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn read_external_agent_runner_status() -> ExternalAgentRunnerStatus {
|
||
let config_dir = external_agent_runner_config_dir();
|
||
read_external_agent_runner_status_at(config_dir.as_deref())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod diagnostic_log_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn runner_log_output_redacts_config_paths_and_credentials() {
|
||
let config_dir = Path::new(r"C:\Users\example\AppData\Roaming\game-chat");
|
||
assert_eq!(
|
||
sanitize_agent_runner_output(
|
||
r"agent.runner.failed: failed to open C:\Users\example\AppData\Roaming\game-chat\state.json",
|
||
config_dir,
|
||
),
|
||
"agent.runner.failed: failed to open <appdata>\\state.json"
|
||
);
|
||
assert_eq!(
|
||
sanitize_agent_runner_output("Authorization: Bearer secret", config_dir),
|
||
"<sensitive runner output redacted>"
|
||
);
|
||
assert_eq!(
|
||
sanitize_agent_runner_output(
|
||
r"agent.runner.failed: project C:\private\game\index.html failed",
|
||
config_dir,
|
||
),
|
||
"agent.runner.failed: project <absolute-path> failed"
|
||
);
|
||
assert_eq!(
|
||
sanitize_agent_runner_output("normal model response body", config_dir),
|
||
"<non-diagnostic runner output omitted>"
|
||
);
|
||
assert_eq!(
|
||
sanitize_agent_runner_output(
|
||
"agent.runner.failed: request failed https://example.invalid/api?value=1",
|
||
config_dir,
|
||
),
|
||
"agent.runner.failed: request failed https://example.invalid/api?<query-redacted>"
|
||
);
|
||
assert_eq!(
|
||
sanitize_agent_runner_output("error password=hunter2", config_dir),
|
||
"<sensitive runner output redacted>"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runner_log_line_reader_caps_long_lines_and_drains_to_next_line() {
|
||
let mut input = vec![b'x'; AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES + 500];
|
||
input.extend_from_slice(b"\nerror: second line\n");
|
||
let mut reader = BufReader::new(std::io::Cursor::new(input));
|
||
let (first, first_truncated) = read_bounded_agent_runner_line(&mut reader)
|
||
.expect("read first line")
|
||
.expect("first line exists");
|
||
assert_eq!(first.len(), AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES);
|
||
assert!(first_truncated);
|
||
let (second, second_truncated) = read_bounded_agent_runner_line(&mut reader)
|
||
.expect("read second line")
|
||
.expect("second line exists");
|
||
assert_eq!(second, "error: second line");
|
||
assert!(!second_truncated);
|
||
}
|
||
}
|