Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs
T
kdletters 33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
接入 DotCraft Unity 编辑器插件与受控执行链路 (#423)
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。

## 主要变更

- 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。
- GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。
- 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。
- Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。

## 验证

- .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。
- Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。
- 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。

Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
2026-09-19 12:29:27 +08:00

407 lines
16 KiB
Rust

use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::collections::VecDeque;
use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str =
"agent-runner.gui-participant.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str =
"agent-runner.gui-owner.claim.json";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_FILE_NAME: &str =
"execution-owner.json";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_PATH: &str =
".agent/runtime/execution-owner.lock";
pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH: &str =
".agent/runtime/execution-owner.json";
// Unity helper 最多返回 2 MiB,额外保留 JSON 转义和 Runner 信封空间。
pub(super) const EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
pub(super) const EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES: u64 = 64 * 1024;
pub(super) const EXTERNAL_AGENT_RUNNER_MAX_OWNER_BYTES: u64 = 16 * 1024;
pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CONNECTIONS: usize = 32;
pub(super) const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512;
pub(super) const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable";
pub(super) const EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_CONNECT_TIMEOUT: Duration =
Duration::from_millis(250);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_IO_TIMEOUT: Duration =
Duration::from_millis(750);
pub(super) const EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT: Duration =
Duration::from_millis(250);
pub(super) const EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT: Duration =
Duration::from_millis(1_500);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_FORCE_TERMINATE_GRACE: Duration =
Duration::from_millis(500);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration =
Duration::from_secs(6 * 60);
pub(super) const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL: Duration =
Duration::from_millis(100);
pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT: Duration =
Duration::from_millis(1_750);
pub(super) const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25);
#[cfg(target_os = "linux")]
pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_EPHEMERAL_PORT_RANGE_PATH: &str =
"/proc/sys/net/ipv4/ip_local_port_range";
#[cfg(target_os = "linux")]
pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_RESERVED_PORTS_PATH: &str =
"/proc/sys/net/ipv4/ip_local_reserved_ports";
#[cfg(target_os = "linux")]
pub(super) const EXTERNAL_AGENT_RUNNER_LINUX_UNPRIVILEGED_PORT_START_PATH: &str =
"/proc/sys/net/ipv4/ip_unprivileged_port_start";
#[cfg(target_os = "linux")]
pub(super) const EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START: u16 = 61_000;
pub(super) static EXTERNAL_AGENT_RUNNER_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> =
OnceLock::new();
pub(super) static EXTERNAL_AGENT_RUNNER_CONFIGURE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub(super) static EXTERNAL_AGENT_RUNNER_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(super) static EXTERNAL_AGENT_RUNNER_SERVER_PROCESS: AtomicBool = AtomicBool::new(false);
pub(super) static EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT: AtomicBool =
AtomicBool::new(false);
pub(super) static EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT: OnceLock<String> = OnceLock::new();
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerEndpoint {
pub(super) protocol_version: u32,
pub(super) pid: u32,
pub(super) boot_id: String,
pub(super) port: u16,
pub(super) token: String,
pub(super) heartbeat_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) executable_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) process_start_identity: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ExternalAgentRunnerReuseDecision {
Reuse,
Retire,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerProjectExecutionOwnerRecord {
pub(super) protocol_version: u32,
pub(super) pid: u32,
pub(super) boot_id: String,
pub(super) acquired_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) recovered_from_boot_id: Option<String>,
}
impl ExternalAgentRunnerProjectExecutionOwnerRecord {
pub(super) fn validate_shape(&self) -> Result<(), String> {
if self.protocol_version == 0 || self.pid == 0 {
return Err("项目 execution-owner 协议版本或 pid 无效".to_string());
}
if self.boot_id.trim().is_empty() || self.boot_id.len() > 128 {
return Err("项目 execution-owner bootId 无效".to_string());
}
if self
.recovered_from_boot_id
.as_deref()
.is_some_and(|value| value.trim().is_empty() || value.len() > 128)
{
return Err("项目 execution-owner recoveredFromBootId 无效".to_string());
}
Ok(())
}
}
impl ExternalAgentRunnerEndpoint {
pub(super) fn validate_shape(&self) -> Result<(), String> {
if self.protocol_version == 0 || self.pid == 0 {
return Err("Agent Runner endpoint 缺少有效 pid".to_string());
}
if self.boot_id.trim().is_empty() || self.boot_id.len() > 128 {
return Err("Agent Runner endpoint bootId 无效".to_string());
}
if self.port == 0 {
return Err("Agent Runner endpoint 端口无效".to_string());
}
if self.token.len() < 32 || self.token.len() > 256 {
return Err("Agent Runner endpoint token 无效".to_string());
}
if self.executable_fingerprint.as_deref().is_some_and(|value| {
value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
}) {
return Err("Agent Runner endpoint executableFingerprint 无效".to_string());
}
if self
.process_start_identity
.as_deref()
.is_some_and(|value| value.is_empty() || value.len() > 128)
{
return Err("Agent Runner endpoint processStartIdentity 无效".to_string());
}
Ok(())
}
}
pub(super) fn external_agent_runner_endpoint_reuse_decision(
endpoint: &ExternalAgentRunnerEndpoint,
executable_fingerprint: &str,
) -> ExternalAgentRunnerReuseDecision {
if endpoint.protocol_version == EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION
&& endpoint.executable_fingerprint.as_deref() == Some(executable_fingerprint)
{
ExternalAgentRunnerReuseDecision::Reuse
} else {
ExternalAgentRunnerReuseDecision::Retire
}
}
pub(super) fn external_agent_runner_executable_fingerprint_at(
path: &Path,
) -> Result<String, String> {
let mut file = File::open(path)
.map_err(|error| format!("打开当前 Agent Runner 可执行文件失败:{error}"))?;
let metadata = file
.metadata()
.map_err(|error| format!("读取当前 Agent Runner 可执行文件元数据失败:{error}"))?;
if !metadata.is_file() {
return Err("当前 Agent Runner 可执行文件不是普通文件".to_string());
}
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("读取当前 Agent Runner 可执行文件失败:{error}"))?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(format!("{:x}", digest.finalize()))
}
pub(super) fn current_external_agent_runner_executable_fingerprint() -> Result<String, String> {
if let Some(fingerprint) = EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT.get() {
return Ok(fingerprint.clone());
}
let executable = std::env::current_exe()
.map_err(|error| format!("定位当前 Agent Runner 可执行文件失败:{error}"))?;
let fingerprint = external_agent_runner_executable_fingerprint_at(&executable)?;
let _ = EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT.set(fingerprint.clone());
Ok(EXTERNAL_AGENT_RUNNER_EXECUTABLE_FINGERPRINT
.get()
.cloned()
.unwrap_or(fingerprint))
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExternalAgentRunnerStatus {
pub(crate) enabled: bool,
pub(crate) running: bool,
pub(crate) protocol_version: u32,
pub(crate) pid: Option<u32>,
pub(crate) boot_id: Option<String>,
pub(crate) port: Option<u16>,
pub(crate) heartbeat_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<String>,
}
impl ExternalAgentRunnerStatus {
pub(super) fn disabled() -> Self {
Self {
enabled: false,
running: false,
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
pid: None,
boot_id: None,
port: None,
heartbeat_at: None,
error: None,
}
}
pub(super) fn from_endpoint(endpoint: &ExternalAgentRunnerEndpoint, running: bool) -> Self {
Self {
enabled: true,
running,
protocol_version: endpoint.protocol_version,
pid: Some(endpoint.pid),
boot_id: Some(endpoint.boot_id.clone()),
port: Some(endpoint.port),
heartbeat_at: Some(endpoint.heartbeat_at),
error: None,
}
}
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerRequestParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) editor_rpc: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) root: Option<String>,
#[serde(default, alias = "agentId", skip_serializing_if = "Option::is_none")]
pub(super) agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) action_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) steer_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) gui_owner_epoch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) gui_owner_session_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_api_base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_generation: Option<u64>,
/// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进
/// revision,但不推进 `platform_auth_generation`(身份代次)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_revision: Option<u64>,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerRequest {
pub(super) protocol_version: u32,
pub(super) request_id: String,
pub(super) token: String,
pub(super) method: String,
#[serde(default)]
pub(super) params: ExternalAgentRunnerRequestParams,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerProtocolError {
pub(super) code: String,
pub(super) message: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ExternalAgentRunnerResponse {
pub(super) protocol_version: u32,
pub(super) request_id: String,
pub(super) ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) error: Option<ExternalAgentRunnerProtocolError>,
}
impl ExternalAgentRunnerResponse {
pub(super) fn success(request_id: &str, result: Value) -> Self {
Self {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: request_id.to_string(),
ok: true,
result: Some(result),
error: None,
}
}
pub(super) fn failure(request_id: &str, code: &str, message: impl Into<String>) -> Self {
Self {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: request_id.to_string(),
ok: false,
result: None,
error: Some(ExternalAgentRunnerProtocolError {
code: code.to_string(),
message: message.into(),
}),
}
}
}
#[derive(Debug)]
pub(super) enum ExternalAgentRunnerFrameError {
Io(io::Error),
Oversize(u32),
}
impl std::fmt::Display for ExternalAgentRunnerFrameError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(formatter, "{error}"),
Self::Oversize(length) => write!(
formatter,
"Agent Runner frame 超过 {} 字节上限:{length}",
EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES
),
}
}
}
impl From<io::Error> for ExternalAgentRunnerFrameError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
#[derive(Clone)]
pub(super) struct CachedExternalAgentRunnerResponse {
pub(super) request_id: String,
pub(super) fingerprint: String,
pub(super) response: ExternalAgentRunnerResponse,
}
#[derive(Default)]
pub(super) struct ExternalAgentRunnerRequestCache {
pub(super) entries: VecDeque<CachedExternalAgentRunnerResponse>,
}
impl ExternalAgentRunnerRequestCache {
pub(super) fn find(&self, request_id: &str) -> Option<&CachedExternalAgentRunnerResponse> {
self.entries
.iter()
.find(|entry| entry.request_id == request_id)
}
pub(super) fn insert(
&mut self,
request_id: String,
fingerprint: String,
response: ExternalAgentRunnerResponse,
) {
if self.entries.len() >= EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS {
self.entries.pop_front();
}
self.entries.push_back(CachedExternalAgentRunnerResponse {
request_id,
fingerprint,
response,
});
}
}