接入 Godot 编辑器插件与受控执行链路

新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存
接入 AGC 插件开关、Runner、Agent 工具与权限审计
完善执行回执确认、不确定状态阻断及卸载恢复
补齐 Windows 分发资源、定向测试与实机验收文档
This commit is contained in:
kdletters
2026-09-20 16:35:04 +08:00
parent 15774a1c02
commit 54d0fb75ea
77 changed files with 11545 additions and 582 deletions
@@ -509,7 +509,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
pub(super) fn external_agent_runner_client_read_timeout(method: &str) -> Duration {
match method {
"runtime.compact" => EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT,
"unity.editor.rpc" => Duration::from_secs(80),
"unity.editor.rpc" | "godot.editor.rpc" => Duration::from_secs(80),
_ => EXTERNAL_AGENT_RUNNER_IO_TIMEOUT,
}
}
@@ -1680,21 +1680,33 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
}
}
pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Result<Value, String> {
pub(crate) fn call_external_managed_editor(
editor: crate::editor_adapters::ManagedEditor,
method: &str,
mut params: Value,
) -> Result<Value, String> {
let deadline = Instant::now() + Duration::from_secs(80);
static EXECUTION_UNCERTAIN: std::sync::atomic::AtomicBool =
static UNITY_UNCERTAIN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static GODOT_UNCERTAIN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
let execution_uncertain = match editor {
crate::editor_adapters::ManagedEditor::Unity => &UNITY_UNCERTAIN,
crate::editor_adapters::ManagedEditor::Godot => &GODOT_UNCERTAIN,
};
let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?;
let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"Unity 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}});
if method == "execute" && EXECUTION_UNCERTAIN.load(std::sync::atomic::Ordering::SeqCst) {
let uncertain_result = || serde_json::json!({"ok":false,"status":"needs-reconciliation","retryAllowed":false,"dispatched":true,"error":{"code":"runner-receipt-unconfirmed","message":"编辑器 执行回执未确认,核对后退出全部 AGC 和 Runner 再重新打开"}});
if method == "execute" && execution_uncertain.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(uncertain_result());
}
if method == "execute"
&& crate::editor_adapters::unity_uncertain_fence_path(&config_dir).exists()
&& crate::editor_adapters::editor_uncertain_fence_path(editor, &config_dir).exists()
{
return Ok(uncertain_result());
}
let endpoint = if crate::editor_adapters::unity_execution_fence_path(&config_dir).exists() {
let endpoint = if crate::editor_adapters::editor_execution_fence_path(editor, &config_dir)
.exists()
{
// 在途 fence 可能只是正常并发;由活着的 owner 区分 busy 与 unknown。
// 此分支绝不自动重启 Runner,以免丢失未确认执行的进程内状态。
match read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))
@@ -1707,7 +1719,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
let _configure = match external_agent_runner_configure_lock().try_lock() {
Ok(guard) => guard,
Err(_) if method == "execute" => {
return Ok(crate::editor_adapters::unity_not_dispatched(
return Ok(crate::editor_adapters::editor_not_dispatched(
"Runner 正在配置,请等待当前操作完成",
))
}
@@ -1718,11 +1730,11 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining < Duration::from_secs(16) {
return if method == "execute" {
Ok(crate::editor_adapters::unity_not_dispatched(
"Unity 调用启动预算已耗尽,未派发执行",
Ok(crate::editor_adapters::editor_not_dispatched(
"编辑器 调用启动预算已耗尽,未派发执行",
))
} else {
Err("Unity 调用启动预算已耗尽".to_string())
Err("编辑器 调用启动预算已耗尽".to_string())
};
}
if let Some(params) = params.as_object_mut() {
@@ -1732,7 +1744,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
.is_some_and(|timeout| (1..=60_000).contains(&timeout))
}) {
return if method == "execute" {
Ok(crate::editor_adapters::unity_not_dispatched(
Ok(crate::editor_adapters::editor_not_dispatched(
"timeoutMs 必须在 1..=60000",
))
} else {
@@ -1746,7 +1758,13 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
let bounded = requested.min(remaining.as_millis().saturating_sub(15_000) as u64);
params.insert("timeoutMs".to_string(), serde_json::json!(bounded));
}
let request_id = random_identifier(b"agc-unity-editor-request")?;
let request_id = random_identifier(b"agc-editor-request")?;
// 所有可能失败的随机身份生成必须在真实执行派发前完成。
let acknowledgement_id = random_identifier(b"agc-editor-ack")?;
let persist_uncertain = || {
execution_uncertain.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir);
};
let request_params = ExternalAgentRunnerRequestParams {
editor_rpc: Some(
serde_json::json!({"method":method,"params":params,"deadlineMs":unix_millis()+remaining.as_millis() as u64}),
@@ -1758,7 +1776,7 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
&endpoint,
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id.clone(),
"unity.editor.rpc",
editor.rpc_method(),
request_params,
Duration::from_secs(2),
remaining.saturating_sub(Duration::from_secs(7)),
@@ -1767,17 +1785,16 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
match response {
Ok(mut value) => {
if method == "execute" {
if !crate::editor_adapters::unity_execute_receipt_is_valid(&value) {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
if !crate::editor_adapters::editor_execute_receipt_is_valid(&value) {
persist_uncertain();
return Ok(uncertain_result());
}
if value["status"] == "needs-reconciliation" {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
persist_uncertain();
return Ok(value);
}
let Some(ack_required) = value.get("ackRequired").and_then(Value::as_bool) else {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir);
persist_uncertain();
return Ok(uncertain_result());
};
if let Some(object) = value.as_object_mut() {
@@ -1787,15 +1804,15 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
return Ok(value);
}
if Instant::now() + Duration::from_secs(3) >= deadline {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
persist_uncertain();
return Ok(uncertain_result());
}
let acknowledgement =
send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
&endpoint,
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
random_identifier(b"agc-unity-ack")?,
"unity.editor.ack",
acknowledgement_id,
editor.ack_method(),
ExternalAgentRunnerRequestParams {
editor_rpc: Some(serde_json::json!({"requestId":request_id})),
..Default::default()
@@ -1805,25 +1822,47 @@ pub(crate) fn call_external_unity_editor(method: &str, mut params: Value) -> Res
Duration::from_millis(500),
);
if !acknowledgement.is_ok_and(|response| response["acknowledged"] == true) {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir);
persist_uncertain();
return Ok(uncertain_result());
}
}
Ok(value)
}
Err(_) if method == "execute" => {
EXECUTION_UNCERTAIN.store(true, std::sync::atomic::Ordering::SeqCst);
persist_uncertain();
Ok(uncertain_result())
}
Err(error) => Err(error),
}
}
pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> {
pub(crate) fn disconnect_external_managed_editor(
editor: crate::editor_adapters::ManagedEditor,
) -> Result<(), String> {
disconnect_external_managed_editor_project(editor, None)
}
pub(crate) fn disconnect_external_managed_editor_project(
editor: crate::editor_adapters::ManagedEditor,
project: Option<&Path>,
) -> Result<(), String> {
let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(());
return if editor == crate::editor_adapters::ManagedEditor::Godot
&& project
.map(crate::editor_adapters::godot_project_cleanup_required)
.transpose()?
.unwrap_or(false)
{
Err("Godot 清理宿主尚未初始化,无法确认旧桥已卸载".into())
} else {
Ok(())
};
};
if editor == crate::editor_adapters::ManagedEditor::Godot {
return disconnect_external_godot_projects(&config_dir, project, |params| {
call_external_managed_editor(editor, "disconnect", params)
});
}
let path = external_agent_runner_endpoint_path(&config_dir);
if !path.exists() {
return Ok(());
@@ -1831,7 +1870,7 @@ pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> {
let endpoint = read_external_agent_runner_endpoint(&path)?;
send_external_agent_runner_request(
&endpoint,
"unity.editor.rpc",
editor.rpc_method(),
ExternalAgentRunnerRequestParams {
editor_rpc: Some(serde_json::json!({"method":"disconnect","params":{}})),
..Default::default()
@@ -1840,17 +1879,72 @@ pub(crate) fn disconnect_external_unity_editor() -> Result<(), String> {
.map(|_| ())
}
pub(crate) fn mark_external_unity_editor_uncertain() -> Result<(), String> {
fn disconnect_external_godot_projects(
config: &Path,
explicit: Option<&Path>,
mut cleanup: impl FnMut(Value) -> Result<Value, String>,
) -> Result<(), String> {
// endpoint 丢失不等于编辑器桥消失;用持久授权根启动原 owner 的清理流程。
for project in crate::editor_adapters::godot_cleanup_projects_at(config, explicit)? {
let result = cleanup(serde_json::json!({"projectPath":project}))?;
if result["adapter"] != "godot-editor"
|| result["connected"] != false
|| result.get("error").is_some()
|| result.get("accepted").is_some()
|| result["status"] == "needs-reconciliation"
{
return Err("Godot 原生桥尚未确认卸载".into());
}
}
Ok(())
}
#[cfg(test)]
mod managed_cleanup_tests {
use super::*;
#[test]
fn godot_cleanup_recovers_authorized_projects_without_runner_endpoint() {
let config = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let expected = project.path().canonicalize().unwrap();
fs::write(
config.path().join("godot-editor-authorized-projects.json"),
serde_json::json!({
"schemaVersion":"agc.godot.authorized-projects.v1", "projects":[expected]
})
.to_string(),
)
.unwrap();
assert!(!external_agent_runner_endpoint_path(config.path()).exists());
let mut called = false;
let result = disconnect_external_godot_projects(config.path(), None, |params| {
called = true;
assert_eq!(params["projectPath"], serde_json::json!(expected));
Ok(serde_json::json!({"accepted":true,"status":"shutting-down"}))
});
assert!(called);
assert!(result.is_err());
assert_eq!(
crate::editor_adapters::godot_authorized_projects_at(config.path()).unwrap(),
vec![expected]
);
}
}
pub(crate) fn mark_external_editor_uncertain(
editor: crate::editor_adapters::ManagedEditor,
) -> Result<(), String> {
let config_dir = external_agent_runner_config_dir().ok_or("外部 Agent Runner 尚未配置")?;
// 先保存 GUI 与 Runner 共享的单向 fence;网络丢失也不能解锁。
crate::editor_adapters::mark_unity_execution_uncertain_at(&config_dir)?;
crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config_dir)?;
let endpoint =
read_external_agent_runner_endpoint(&external_agent_runner_endpoint_path(&config_dir))?;
send_external_agent_runner_request_with_protocol_and_id_and_timeouts(
&endpoint,
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
random_identifier(b"agc-unity-mark-uncertain")?,
"unity.editor.mark_uncertain",
random_identifier(b"agc-editor-mark-uncertain")?,
editor.mark_method(),
ExternalAgentRunnerRequestParams::default(),
Duration::from_millis(500),
Duration::from_secs(1),
@@ -236,7 +236,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
}
fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool {
method.starts_with("runtime.") || method.starts_with("unity.editor.")
method.starts_with("runtime.")
|| method.starts_with("unity.editor.")
|| method.starts_with("godot.editor.")
}
pub(super) fn external_agent_runner_request_session_id(
@@ -1031,8 +1033,10 @@ pub(super) fn handle_external_agent_runner_request(
}
match request.method.as_str() {
// 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 Unity。
"unity.editor.rpc" => {
// 编辑器使用自身的有界并发门闩;不能持有 Runtime 全局写请求缓存锁等待 编辑器。
"unity.editor.rpc" | "godot.editor.rpc" => {
let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method)
.expect("matched editor RPC");
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct EditorCall {
@@ -1048,14 +1052,15 @@ pub(super) fn handle_external_agent_runner_request(
let call: EditorCall = serde_json::from_value(
request.params.editor_rpc.clone().ok_or("缺少 editorRpc")?,
)
.map_err(|_| "Unity RPC 参数无效".to_string())?;
.map_err(|_| "编辑器 RPC 参数无效".to_string())?;
if call
.deadline_ms
.is_some_and(|deadline| deadline <= unix_millis())
{
return Err("Unity RPC 派发期限已过,未发送执行".to_string());
return Err("编辑器 RPC 派发期限已过,未发送执行".to_string());
}
crate::editor_adapters::unity_editor_rpc_owned(
crate::editor_adapters::managed_editor_rpc_owned(
editor,
&call.method,
call.params,
Some(&request.request_id),
@@ -1084,25 +1089,27 @@ pub(super) fn handle_external_agent_runner_request(
.and_then(|value| value["method"].as_str())
== Some("execute") =>
{
let mut value = crate::editor_adapters::unity_not_dispatched(&error);
let mut value = crate::editor_adapters::editor_not_dispatched(&error);
value["ackRequired"] = json!(false);
ExternalAgentRunnerResponse::success(&request.request_id, value)
}
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"unity-editor-failed",
"editor-rpc-failed",
error,
),
}
}
"unity.editor.ack" => {
"unity.editor.ack" | "godot.editor.ack" => {
let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method)
.expect("matched editor ACK");
let result = request
.params
.editor_rpc
.as_ref()
.and_then(|value| value["requestId"].as_str())
.ok_or_else(|| "缺少 Unity 回执身份".to_string())
.and_then(crate::editor_adapters::acknowledge_unity_editor_delivery);
.ok_or_else(|| "缺少 编辑器 回执身份".to_string())
.and_then(|id| crate::editor_adapters::acknowledge_editor_delivery(editor, id));
match result {
Ok(()) => ExternalAgentRunnerResponse::success(
&request.request_id,
@@ -1110,20 +1117,22 @@ pub(super) fn handle_external_agent_runner_request(
),
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"unity-ack-failed",
"editor-ack-failed",
error,
),
}
}
"unity.editor.mark_uncertain" => {
match crate::editor_adapters::mark_unity_execution_uncertain() {
"unity.editor.mark_uncertain" | "godot.editor.mark_uncertain" => {
let editor = crate::editor_adapters::ManagedEditor::from_rpc_method(&request.method)
.expect("matched editor uncertain RPC");
match crate::editor_adapters::mark_editor_execution_uncertain(editor) {
Ok(()) => ExternalAgentRunnerResponse::success(
&request.request_id,
json!({"status":"needs-reconciliation"}),
),
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"unity-mark-failed",
"editor-mark-failed",
error,
),
}
@@ -1032,7 +1032,7 @@ pub(crate) fn acquire_external_agent_runner_gui_participant_lock(
"Agent Runner 单实例锁",
)?;
if runner.is_some() {
crate::editor_adapters::reset_unity_execution_fence_for_fresh_gui(config_dir)?;
crate::editor_adapters::reset_editor_execution_fences_for_fresh_gui(config_dir)?;
}
drop(participant);
runner
@@ -214,6 +214,7 @@ pub(crate) fn run_external_agent_runner_server(
EXTERNAL_AGENT_RUNNER_SERVER_PROCESS.store(true, Ordering::Release);
crate::set_game_creator_runtime_config_dir(config_dir.clone());
crate::editor_adapters::configure_unity_helper_for_runtime()?;
crate::editor_adapters::configure_godot_payload_for_runtime(&config_dir)?;
set_external_agent_runner_config_dir(config_dir.clone());
let boot_id = random_identifier(b"genarrative-agent-runner-boot-id")?;
@@ -21,47 +21,53 @@ use crate::{
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn unity_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() {
let directory = unique_test_directory();
let config = private_runner_test_config_dir(&directory);
let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
let fence = crate::editor_adapters::unity_execution_fence_path(&config);
fs::write(&fence, "unknown-request").unwrap();
crate::editor_adapters::mark_unity_execution_uncertain_at(&config).unwrap();
let uncertain_fence = crate::editor_adapters::unity_uncertain_fence_path(&config);
let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(
fence.exists(),
"new window must not clear pending execution"
);
drop(second);
let runner = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config),
"unity-test-boot",
)
.unwrap();
drop(first);
let while_runner_alive = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(fence.exists(), "running owner prevents recovery");
drop(while_runner_alive);
drop(runner);
let restarted_runner = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config),
"unity-test-boot-2",
)
.unwrap();
assert!(
fence.exists(),
"automatic Runner restart must not clear pending execution"
);
assert!(uncertain_fence.exists());
drop(restarted_runner);
let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(
!fence.exists(),
"all GUI and Runner exited: a fresh GUI can recover"
);
assert!(!uncertain_fence.exists());
fn editor_pending_execution_survives_new_window_and_runner_restart_until_full_gui_restart() {
for editor in [
crate::editor_adapters::ManagedEditor::Unity,
crate::editor_adapters::ManagedEditor::Godot,
] {
let directory = unique_test_directory();
let config = private_runner_test_config_dir(&directory);
let first = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
let fence = crate::editor_adapters::editor_execution_fence_path(editor, &config);
fs::write(&fence, "unknown-request").unwrap();
crate::editor_adapters::mark_editor_execution_uncertain_at(editor, &config).unwrap();
let uncertain_fence = crate::editor_adapters::editor_uncertain_fence_path(editor, &config);
let second = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(
fence.exists(),
"new window must not clear pending execution"
);
drop(second);
let runner = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config),
"editor-test-boot",
)
.unwrap();
drop(first);
let while_runner_alive =
acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(fence.exists(), "running owner prevents recovery");
drop(while_runner_alive);
drop(runner);
let restarted_runner = acquire_external_agent_runner_instance_lock(
&external_agent_runner_lock_path(&config),
"editor-test-boot-2",
)
.unwrap();
assert!(
fence.exists(),
"automatic Runner restart must not clear pending execution"
);
assert!(uncertain_fence.exists());
drop(restarted_runner);
let _fresh = acquire_external_agent_runner_gui_participant_lock(&config).unwrap();
assert!(
!fence.exists(),
"all GUI and Runner exited: a fresh GUI can recover"
);
assert!(!uncertain_fence.exists());
}
}
struct TestDirectoryGuard(PathBuf);