0594a90bdd
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust crates (push) Successful in 3m1s
Project CI / Frontend tests (push) Failing after 4m24s
Project CI / Repository checks (push) Successful in 4m1s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / AI game creator shell web tests (push) Failing after 3m4s
Project CI / Backend tests (push) Successful in 8m20s
平台会话快照拆分身份代次与写入 revision,凭据续期轮换不再中断在途生成 AGC Runner 请求参数新增 platform_auth_revision,并按 revision 做单调写入判定 解决与多窗口共享 Runner 的冲突,保留 claim adopt/publish 与界面参与锁语义 同步刷新轮换竞争、appSurface 鉴权用例与客户端 API 测试 补录平台会话身份与凭据分离的实施计划、共享记忆与决策记录
4309 lines
160 KiB
Rust
4309 lines
160 KiB
Rust
use super::{
|
||
client::*, dispatch::*, endpoint::*, project_owner::*, protocol::*, server::*, state::*,
|
||
};
|
||
use serde_json::{json, Value};
|
||
use sha2::{Digest as _, Sha256};
|
||
use std::collections::BTreeSet;
|
||
use std::ffi::OsString;
|
||
use std::fs;
|
||
use std::io::{self, Cursor};
|
||
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||
use std::sync::{mpsc, Arc, Barrier, Mutex};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use crate::{
|
||
default_agent_runtime_run_profile, unix_timestamp, AgentRuntimeTaskRecord,
|
||
ProcessSessionRecord, AGENT_RUNTIME_SCHEMA_VERSION,
|
||
};
|
||
|
||
static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
|
||
struct TestDirectoryGuard(PathBuf);
|
||
|
||
impl Drop for TestDirectoryGuard {
|
||
fn drop(&mut self) {
|
||
let _ = fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
fn unique_test_directory() -> TestDirectoryGuard {
|
||
let sequence = TEST_DIRECTORY_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||
let path = std::env::temp_dir().join(format!(
|
||
"genarrative-agent-runner-test-{}-{}-{sequence}",
|
||
std::process::id(),
|
||
unix_millis()
|
||
));
|
||
fs::create_dir_all(&path).expect("create runner test directory");
|
||
TestDirectoryGuard(path)
|
||
}
|
||
|
||
fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf {
|
||
crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData")
|
||
}
|
||
|
||
/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。
|
||
struct TestGuiParticipant {
|
||
_lock: ExternalAgentRunnerGuiParticipantLock,
|
||
owner_epoch: String,
|
||
}
|
||
|
||
fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant {
|
||
let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)
|
||
.expect("acquire GUI participant lock");
|
||
let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision)
|
||
.expect("publish GUI owner claim");
|
||
TestGuiParticipant {
|
||
_lock: lock,
|
||
owner_epoch: claim.owner_epoch,
|
||
}
|
||
}
|
||
|
||
fn acquire_project_owner_after_release(
|
||
root: &Path,
|
||
boot_id: &str,
|
||
) -> ExternalAgentRunnerProjectExecutionOwner {
|
||
let mut last_error = None;
|
||
for attempt in 0..100 {
|
||
match acquire_external_agent_runner_project_execution_owner(
|
||
root,
|
||
boot_id,
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
) {
|
||
Ok(owner) => return owner,
|
||
Err(error) if error.contains("另一个 Agent Runner") && attempt < 99 => {
|
||
last_error = Some(error);
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
Err(error) => panic!("OS lock owner recovery failed: {error}"),
|
||
}
|
||
}
|
||
panic!(
|
||
"OS lock owner was not released: {}",
|
||
last_error.unwrap_or_else(|| "unknown lock error".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn context_compaction_client_uses_long_response_timeout_without_widening_other_methods() {
|
||
assert_eq!(
|
||
external_agent_runner_client_read_timeout("runtime.compact"),
|
||
EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT
|
||
);
|
||
assert!(
|
||
external_agent_runner_client_read_timeout("runtime.compact")
|
||
> EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
|
||
);
|
||
assert_eq!(
|
||
external_agent_runner_client_read_timeout("runtime.start"),
|
||
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
|
||
);
|
||
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 7);
|
||
}
|
||
|
||
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
|
||
ExternalAgentRunnerEndpoint {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
pid: std::process::id(),
|
||
boot_id: boot_id.to_string(),
|
||
port,
|
||
token: token.to_string(),
|
||
heartbeat_at: 1_725_000_000_000,
|
||
executable_fingerprint: Some("a".repeat(64)),
|
||
process_start_identity: None,
|
||
}
|
||
}
|
||
|
||
fn spawn_identity_ping_fixture(
|
||
token: &str,
|
||
endpoint_boot_id: &str,
|
||
response_boot_id: &str,
|
||
) -> (ExternalAgentRunnerEndpoint, std::thread::JoinHandle<()>) {
|
||
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||
.expect("bind identity ping fixture");
|
||
let port = listener
|
||
.local_addr()
|
||
.expect("identity fixture address")
|
||
.port();
|
||
let endpoint = test_endpoint(token, endpoint_boot_id, port);
|
||
let response_boot_id = response_boot_id.to_string();
|
||
let handle = std::thread::spawn(move || {
|
||
let (mut stream, _) = listener.accept().expect("accept identity ping");
|
||
let payload = read_external_agent_runner_frame(&mut stream).expect("read identity ping");
|
||
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
|
||
.expect("parse identity ping");
|
||
let response = ExternalAgentRunnerResponse::success(
|
||
&request.request_id,
|
||
json!({
|
||
"status": "ok",
|
||
"pid": std::process::id(),
|
||
"bootId": response_boot_id,
|
||
}),
|
||
);
|
||
let response = serde_json::to_vec(&response).expect("serialize identity ping response");
|
||
write_external_agent_runner_frame(&mut stream, &response)
|
||
.expect("write identity ping response");
|
||
});
|
||
(endpoint, handle)
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() {
|
||
let token = "legacy-ping-token-legacy-ping-token";
|
||
let (endpoint, server) =
|
||
spawn_identity_ping_fixture(token, "legacy-ping-boot", "legacy-ping-boot");
|
||
verify_external_agent_runner_ping_identity(&endpoint)
|
||
.expect("matching authenticated ping authorizes legacy identity");
|
||
server.join().expect("join matching identity fixture");
|
||
|
||
let (endpoint, server) =
|
||
spawn_identity_ping_fixture(token, "legacy-ping-boot", "different-boot");
|
||
let error = verify_external_agent_runner_ping_identity(&endpoint)
|
||
.expect_err("mismatched boot must reject legacy identity");
|
||
assert!(error.contains("身份与 endpoint 不匹配"));
|
||
server.join().expect("join mismatched identity fixture");
|
||
}
|
||
|
||
#[test]
|
||
fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
|
||
let token = "client-exit-response-token-client-exit-response-token";
|
||
|
||
let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||
.expect("bind busy client-exit fixture");
|
||
let busy_endpoint = test_endpoint(
|
||
token,
|
||
"client-exit-busy-response-boot",
|
||
busy_listener
|
||
.local_addr()
|
||
.expect("busy fixture address")
|
||
.port(),
|
||
);
|
||
write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint)
|
||
.expect("write busy client-exit endpoint");
|
||
let busy_server = std::thread::spawn(move || {
|
||
let (mut stream, _) = busy_listener.accept().expect("accept busy client exit");
|
||
let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit");
|
||
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
|
||
.expect("parse busy client exit");
|
||
assert_eq!(request.method, "runner.shutdown_for_client_exit");
|
||
let response = ExternalAgentRunnerResponse::success(
|
||
&request.request_id,
|
||
json!({ "accepted": false, "busy": true, "willShutdown": false }),
|
||
);
|
||
write_external_agent_runner_frame(
|
||
&mut stream,
|
||
&serde_json::to_vec(&response).expect("serialize busy client-exit response"),
|
||
)
|
||
.expect("write busy client-exit response");
|
||
});
|
||
|
||
let started = Instant::now();
|
||
assert!(
|
||
!shutdown_external_agent_runner_for_client_exit_at(&config_dir)
|
||
.expect("busy client exit remains a successful refusal")
|
||
);
|
||
assert!(
|
||
started.elapsed() < Duration::from_secs(2),
|
||
"busy client exit must not wait for Runner boot shutdown"
|
||
);
|
||
busy_server.join().expect("join busy client-exit fixture");
|
||
|
||
let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||
.expect("bind idle client-exit fixture");
|
||
let idle_endpoint = test_endpoint(
|
||
token,
|
||
"client-exit-idle-response-boot",
|
||
idle_listener
|
||
.local_addr()
|
||
.expect("idle fixture address")
|
||
.port(),
|
||
);
|
||
write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint)
|
||
.expect("write idle client-exit endpoint");
|
||
let idle_endpoint_path = endpoint_path.clone();
|
||
let idle_server = std::thread::spawn(move || {
|
||
let (mut stream, _) = idle_listener.accept().expect("accept idle client exit");
|
||
let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit");
|
||
let request = serde_json::from_slice::<ExternalAgentRunnerRequest>(&payload)
|
||
.expect("parse idle client exit");
|
||
assert_eq!(request.method, "runner.shutdown_for_client_exit");
|
||
let response = ExternalAgentRunnerResponse::success(
|
||
&request.request_id,
|
||
json!({ "accepted": true, "busy": false, "willShutdown": true }),
|
||
);
|
||
write_external_agent_runner_frame(
|
||
&mut stream,
|
||
&serde_json::to_vec(&response).expect("serialize idle client-exit response"),
|
||
)
|
||
.expect("write idle client-exit response");
|
||
drop(stream);
|
||
fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response");
|
||
});
|
||
|
||
assert!(
|
||
shutdown_external_agent_runner_for_client_exit_at(&config_dir)
|
||
.expect("idle client exit must complete Runner shutdown")
|
||
);
|
||
idle_server.join().expect("join idle client-exit fixture");
|
||
}
|
||
|
||
#[test]
|
||
fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() {
|
||
let endpoint = test_endpoint(
|
||
"shape-private-token-shape-private-token",
|
||
"shape-boot-id",
|
||
12001,
|
||
);
|
||
let mut legacy_value = serde_json::to_value(&endpoint).expect("serialize endpoint");
|
||
legacy_value
|
||
.as_object_mut()
|
||
.expect("endpoint object")
|
||
.remove("executableFingerprint");
|
||
let mut endpoint = serde_json::from_value::<ExternalAgentRunnerEndpoint>(legacy_value)
|
||
.expect("deserialize legacy endpoint without fingerprint");
|
||
assert_eq!(endpoint.executable_fingerprint, None);
|
||
endpoint
|
||
.validate_shape()
|
||
.expect("legacy endpoint remains readable for orderly retirement");
|
||
|
||
endpoint.executable_fingerprint = Some("f".repeat(63));
|
||
assert!(endpoint.validate_shape().is_err());
|
||
endpoint.executable_fingerprint = Some(format!("{}g", "f".repeat(63)));
|
||
assert!(endpoint.validate_shape().is_err());
|
||
endpoint.executable_fingerprint = Some("ABCDEF0123456789".repeat(4));
|
||
endpoint
|
||
.validate_shape()
|
||
.expect("64 hexadecimal digits are valid");
|
||
}
|
||
|
||
#[test]
|
||
fn executable_fingerprint_hashes_file_contents_with_sha256() {
|
||
let directory = unique_test_directory();
|
||
let executable = directory.0.join("runner-binary");
|
||
fs::write(&executable, b"abc").expect("write executable fixture");
|
||
|
||
assert_eq!(
|
||
external_agent_runner_executable_fingerprint_at(&executable)
|
||
.expect("fingerprint executable fixture"),
|
||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runner_start_timeout_covers_cold_debug_binary_fingerprinting() {
|
||
assert!(EXTERNAL_AGENT_RUNNER_START_TIMEOUT >= Duration::from_secs(30));
|
||
}
|
||
|
||
#[test]
|
||
fn startup_runner_ping_timeout_rejects_an_exhausted_budget() {
|
||
let endpoint = test_endpoint(
|
||
"startup-ping-timeout-token",
|
||
"startup-ping-timeout-boot",
|
||
31_338,
|
||
);
|
||
let error = ping_external_agent_runner_with_timeout(&endpoint, Duration::ZERO)
|
||
.expect_err("an exhausted startup budget must not attempt a runner ping");
|
||
assert!(error.contains("超时预算已耗尽"), "{error}");
|
||
}
|
||
|
||
#[test]
|
||
fn forced_runner_drain_deadline_precedes_gui_hard_kill_deadline() {
|
||
assert!(
|
||
EXTERNAL_AGENT_RUNNER_FORCED_WORKER_DRAIN_TIMEOUT
|
||
< EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
|
||
);
|
||
assert!(
|
||
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
|
||
< EXTERNAL_AGENT_RUNNER_GUI_SHUTDOWN_EXIT_TIMEOUT
|
||
);
|
||
assert!(
|
||
EXTERNAL_AGENT_RUNNER_FORCED_TOTAL_DRAIN_TIMEOUT
|
||
< EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owned_runner_rejects_start_after_owner_was_already_lost() {
|
||
assert!(resolve_external_agent_runner_initial_gui_owner(true, false).is_err());
|
||
assert!(resolve_external_agent_runner_initial_gui_owner(true, true).unwrap());
|
||
assert!(!resolve_external_agent_runner_initial_gui_owner(false, false).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn gui_runner_launch_arguments_bind_owner_requirement_to_gui_launches_only() {
|
||
let config_dir = Path::new("/private/app-data");
|
||
let gui_arguments = external_agent_runner_launch_arguments(config_dir, true);
|
||
let cli_arguments = external_agent_runner_launch_arguments(config_dir, false);
|
||
|
||
assert_eq!(
|
||
gui_arguments,
|
||
vec![
|
||
"--agent-runner",
|
||
"--config-dir",
|
||
"/private/app-data",
|
||
"--gui-owner-required"
|
||
]
|
||
.into_iter()
|
||
.map(OsString::from)
|
||
.collect::<Vec<_>>()
|
||
);
|
||
assert!(!cli_arguments
|
||
.iter()
|
||
.any(|argument| argument == "--gui-owner-required"));
|
||
}
|
||
|
||
#[test]
|
||
fn endpoint_reuse_requires_current_protocol_and_executable_identity() {
|
||
let current_fingerprint = "b".repeat(64);
|
||
let mut endpoint = test_endpoint(
|
||
"reuse-private-token-reuse-private-token",
|
||
"reuse-boot-id",
|
||
12002,
|
||
);
|
||
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
|
||
assert_eq!(
|
||
external_agent_runner_endpoint_reuse_decision(&endpoint, ¤t_fingerprint),
|
||
ExternalAgentRunnerReuseDecision::Reuse
|
||
);
|
||
|
||
endpoint.executable_fingerprint = None;
|
||
assert_eq!(
|
||
external_agent_runner_endpoint_reuse_decision(&endpoint, ¤t_fingerprint),
|
||
ExternalAgentRunnerReuseDecision::Retire
|
||
);
|
||
endpoint.executable_fingerprint = Some("c".repeat(64));
|
||
assert_eq!(
|
||
external_agent_runner_endpoint_reuse_decision(&endpoint, ¤t_fingerprint),
|
||
ExternalAgentRunnerReuseDecision::Retire
|
||
);
|
||
endpoint.executable_fingerprint = Some(current_fingerprint.clone());
|
||
endpoint.protocol_version += 1;
|
||
assert_eq!(
|
||
external_agent_runner_endpoint_reuse_decision(&endpoint, ¤t_fingerprint),
|
||
ExternalAgentRunnerReuseDecision::Retire
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn idle_runner_shutdown_treats_missing_endpoint_as_already_stopped() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
assert!(shutdown_external_agent_runner_if_idle_at(&config_dir)
|
||
.expect("missing endpoint should already be stopped"));
|
||
}
|
||
|
||
#[test]
|
||
fn gui_runner_shutdown_rejects_missing_endpoint_while_runner_lock_is_held() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let _lock = acquire_external_agent_runner_instance_lock(
|
||
&external_agent_runner_lock_path(&config_dir),
|
||
"gui-shutdown-held-lock",
|
||
)
|
||
.expect("hold runner lock without endpoint");
|
||
let started = Instant::now();
|
||
|
||
let error = shutdown_external_agent_runner_at(&config_dir)
|
||
.expect_err("held Runner lock means missing endpoint is not proof of shutdown");
|
||
|
||
assert!(error.contains("实例锁仍被占用"));
|
||
assert!(
|
||
started.elapsed() < Duration::from_secs(2),
|
||
"GUI shutdown must not inherit the 30 second runner startup wait"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_runner_shutdown_has_a_short_hard_timeout_for_an_unresponsive_endpoint() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||
.expect("bind unresponsive runner fixture");
|
||
let port = listener.local_addr().expect("fixture address").port();
|
||
let endpoint = test_endpoint(
|
||
"gui-timeout-private-token-gui-timeout-private-token",
|
||
"gui-timeout-boot-id",
|
||
port,
|
||
);
|
||
write_external_agent_runner_endpoint_atomic(
|
||
&external_agent_runner_endpoint_path(&config_dir),
|
||
&endpoint,
|
||
)
|
||
.expect("write unresponsive endpoint");
|
||
std::thread::spawn(move || {
|
||
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
|
||
std::thread::sleep(Duration::from_secs(2));
|
||
});
|
||
let started = Instant::now();
|
||
|
||
let error = shutdown_external_agent_runner_at(&config_dir)
|
||
.expect_err("unresponsive runner must hit the GUI shutdown deadline");
|
||
|
||
assert!(error.contains("读取 Agent Runner 响应失败"));
|
||
assert!(
|
||
started.elapsed() < Duration::from_secs(2),
|
||
"GUI shutdown must not block on the normal 10/30 second runner deadlines"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn gui_runner_shutdown_terminates_a_verified_unresponsive_runner_process() {
|
||
let directory = unique_test_directory();
|
||
let mut runner_process = std::process::Command::new("sleep")
|
||
.arg("30")
|
||
.spawn()
|
||
.expect("spawn unresponsive runner process fixture");
|
||
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))
|
||
.expect("bind unresponsive runner fixture");
|
||
let port = listener.local_addr().expect("fixture address").port();
|
||
let mut endpoint = test_endpoint(
|
||
"gui-force-private-token-gui-force-private-token",
|
||
"gui-force-boot-id",
|
||
port,
|
||
);
|
||
endpoint.pid = runner_process.id();
|
||
endpoint.process_start_identity =
|
||
external_agent_runner_process_start_identity(runner_process.id())
|
||
.expect("read runner process start identity");
|
||
write_external_agent_runner_endpoint_atomic(
|
||
&external_agent_runner_endpoint_path(&directory.0),
|
||
&endpoint,
|
||
)
|
||
.expect("write unresponsive endpoint");
|
||
std::thread::spawn(move || {
|
||
let (_stream, _) = listener.accept().expect("accept GUI shutdown request");
|
||
std::thread::sleep(Duration::from_secs(2));
|
||
});
|
||
let started = Instant::now();
|
||
|
||
shutdown_external_agent_runner_at(&directory.0)
|
||
.expect("verified unresponsive Runner must be terminated");
|
||
let status = runner_process
|
||
.wait()
|
||
.expect("reap terminated Runner fixture");
|
||
|
||
assert!(!status.success());
|
||
assert!(!external_agent_runner_endpoint_path(&directory.0).exists());
|
||
assert!(
|
||
started.elapsed() < Duration::from_secs(2),
|
||
"forced GUI shutdown must remain bounded"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn gui_runner_shutdown_fails_closed_without_exact_process_start_identity() {
|
||
let directory = unique_test_directory();
|
||
let mut candidate = std::process::Command::new("sleep")
|
||
.arg("30")
|
||
.spawn()
|
||
.expect("spawn candidate process fixture");
|
||
let mut endpoint = test_endpoint(
|
||
"legacy-force-private-token-legacy-force-private-token",
|
||
"legacy-force-boot-id",
|
||
9,
|
||
);
|
||
endpoint.pid = candidate.id();
|
||
endpoint.process_start_identity = None;
|
||
write_external_agent_runner_endpoint_atomic(
|
||
&external_agent_runner_endpoint_path(&directory.0),
|
||
&endpoint,
|
||
)
|
||
.expect("write legacy endpoint");
|
||
|
||
let legacy_error = shutdown_external_agent_runner_at(&directory.0)
|
||
.expect_err("legacy endpoint must not authorize process termination");
|
||
assert!(legacy_error.contains("安全迁移失败") || legacy_error.contains("连接 Agent Runner"));
|
||
assert!(candidate.try_wait().expect("probe candidate").is_none());
|
||
|
||
endpoint.process_start_identity = Some("not-the-candidate-start-time".to_string());
|
||
write_external_agent_runner_endpoint_atomic(
|
||
&external_agent_runner_endpoint_path(&directory.0),
|
||
&endpoint,
|
||
)
|
||
.expect("write mismatched endpoint");
|
||
let mismatch_error = shutdown_external_agent_runner_at(&directory.0)
|
||
.expect_err("mismatched process identity must not authorize termination");
|
||
assert!(mismatch_error.contains("pid 已被其他进程复用"));
|
||
assert!(candidate.try_wait().expect("probe candidate").is_none());
|
||
|
||
candidate.kill().expect("stop candidate fixture");
|
||
candidate.wait().expect("reap candidate fixture");
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn idle_runner_shutdown_rejects_symlinked_endpoint() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let directory = unique_test_directory();
|
||
let target = directory.0.join("endpoint-target.json");
|
||
fs::write(&target, b"{}").expect("write endpoint target");
|
||
symlink(
|
||
&target,
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
)
|
||
.expect("create endpoint symlink");
|
||
|
||
let error = shutdown_external_agent_runner_if_idle_at(&directory.0)
|
||
.expect_err("endpoint symlink must be rejected");
|
||
assert!(error.contains("符号链接"));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn runner_endpoint_rejects_hard_links() {
|
||
let directory = unique_test_directory();
|
||
let endpoint_path = external_agent_runner_endpoint_path(&directory.0);
|
||
write_external_agent_runner_endpoint_atomic(
|
||
&endpoint_path,
|
||
&test_endpoint(
|
||
"hardlink-endpoint-token-hardlink-endpoint-token",
|
||
"hardlink-endpoint-boot",
|
||
31318,
|
||
),
|
||
)
|
||
.expect("write endpoint");
|
||
fs::hard_link(&endpoint_path, directory.0.join("endpoint-hardlink.json"))
|
||
.expect("create endpoint hard link");
|
||
|
||
let error = match read_external_agent_runner_endpoint(&endpoint_path) {
|
||
Ok(_) => panic!("hard-linked endpoint must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
assert!(error.contains("硬链接"));
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_replays_once_for_each_runner_boot() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("registered-gui-appdata");
|
||
let event_sink_port = 31_317;
|
||
let event_sink_token = "a".repeat(64);
|
||
let params = ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(event_sink_port),
|
||
event_sink_token: Some(event_sink_token.clone()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
};
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
params,
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
|
||
let calls = std::cell::RefCell::new(Vec::new());
|
||
let endpoint_a = test_endpoint(
|
||
"gui-owner-replay-token-gui-owner-replay-token",
|
||
"gui-owner-boot-a",
|
||
31318,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint_a,
|
||
|endpoint, params| {
|
||
calls.borrow_mut().push((
|
||
endpoint.boot_id.clone(),
|
||
params
|
||
.event_sink_port
|
||
.expect("registered sink port is retained"),
|
||
params
|
||
.event_sink_token
|
||
.expect("registered sink token is retained"),
|
||
));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("first boot attaches");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint_a,
|
||
|_, _| panic!("same boot must not attach twice"),
|
||
)
|
||
.expect("same boot is idempotent");
|
||
|
||
let endpoint_b = test_endpoint(
|
||
"gui-owner-replay-token-gui-owner-replay-token",
|
||
"gui-owner-boot-b",
|
||
31319,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint_b,
|
||
|endpoint, params| {
|
||
calls.borrow_mut().push((
|
||
endpoint.boot_id.clone(),
|
||
params
|
||
.event_sink_port
|
||
.expect("registered sink port is replayed"),
|
||
params
|
||
.event_sink_token
|
||
.expect("registered sink token is replayed"),
|
||
));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("replacement boot reattaches");
|
||
|
||
assert_eq!(
|
||
calls.into_inner(),
|
||
vec![
|
||
(
|
||
"gui-owner-boot-a".to_string(),
|
||
event_sink_port,
|
||
event_sink_token.clone(),
|
||
),
|
||
(
|
||
"gui-owner-boot-b".to_string(),
|
||
event_sink_port,
|
||
event_sink_token,
|
||
),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_replays_only_the_latest_platform_session() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("platform-session-replay-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_330),
|
||
event_sink_token: Some("f".repeat(64)),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
remember_external_agent_runner_platform_session(
|
||
&state,
|
||
Some(("user-a", "token-a", "https://dev.genarrative.world")),
|
||
4,
|
||
4,
|
||
)
|
||
.expect("remember owner A session");
|
||
remember_external_agent_runner_platform_session(&state, None, 5, 5)
|
||
.expect("remember logged-out session");
|
||
remember_external_agent_runner_platform_session(
|
||
&state,
|
||
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
|
||
4,
|
||
4,
|
||
)
|
||
.expect("ignore stale owner A session");
|
||
remember_external_agent_runner_platform_session(
|
||
&state,
|
||
Some((
|
||
"user-a",
|
||
"same-generation-token",
|
||
"https://dev.genarrative.world",
|
||
)),
|
||
5,
|
||
5,
|
||
)
|
||
.expect("ignore conflicting same-generation session");
|
||
remember_external_agent_runner_platform_session(
|
||
&state,
|
||
Some(("user-b", "token-b", "https://dev.genarrative.world")),
|
||
6,
|
||
6,
|
||
)
|
||
.expect("remember latest owner B session");
|
||
|
||
let endpoint = test_endpoint(
|
||
"platform-session-replay-runner-token",
|
||
"platform-session-replay-boot",
|
||
31_330,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
|
||
assert_eq!(params.platform_access_token.as_deref(), Some("token-b"));
|
||
assert_eq!(
|
||
params.platform_api_base_url.as_deref(),
|
||
Some("https://dev.genarrative.world")
|
||
);
|
||
assert_eq!(params.platform_auth_generation, Some(6));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("latest platform session is replayed");
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("platform-session-dirty-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_331),
|
||
event_sink_token: Some("d".repeat(64)),
|
||
platform_user_id: Some("user-a".to_string()),
|
||
platform_access_token: Some("token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(1),
|
||
platform_auth_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"platform-session-dirty-runner-token",
|
||
"platform-session-dirty-boot",
|
||
31_331,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
assert_eq!(params.platform_user_id.as_deref(), Some("user-a"));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("attach owner A");
|
||
|
||
remember_external_agent_runner_platform_session(&state, None, 2, 2)
|
||
.expect("remember logged-out session");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
assert_eq!(params.platform_auth_generation, Some(2));
|
||
assert_eq!(params.platform_user_id, None);
|
||
assert_eq!(params.platform_access_token, None);
|
||
assert_eq!(params.platform_api_base_url, None);
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("same boot reattaches the logged-out generation");
|
||
}
|
||
|
||
#[test]
|
||
fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("platform-session-stale-attach-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
platform_user_id: Some("user-a".to_string()),
|
||
platform_access_token: Some("token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(1),
|
||
platform_auth_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"platform-session-stale-attach-token",
|
||
"platform-session-stale-attach-boot",
|
||
31_332,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
assert_eq!(params.platform_user_id.as_deref(), Some("user-a"));
|
||
remember_external_agent_runner_platform_session(
|
||
&state,
|
||
Some(("user-b", "token-b", "https://dev.genarrative.world")),
|
||
2,
|
||
2,
|
||
)
|
||
.expect("remember owner B while owner A attach is in flight");
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("stale owner A attach response returns");
|
||
|
||
let retries = std::cell::Cell::new(0_u32);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
retries.set(retries.get() + 1);
|
||
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
|
||
assert_eq!(params.platform_auth_generation, Some(2));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("new owner B remains pending after stale completion");
|
||
assert_eq!(retries.get(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_platform_session_payload_clears_runner_session() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint("platform-clear-token", "platform-clear-boot", 31_333),
|
||
);
|
||
let _session = crate::install_test_platform_session(
|
||
"runner-owner-a",
|
||
"runner-token-a",
|
||
"https://dev.genarrative.world",
|
||
);
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_auth_generation: Some(2),
|
||
platform_auth_revision: Some(2),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("explicit logged-out payload clears Runner session");
|
||
assert_eq!(crate::current_platform_session(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_partial_platform_session_payload_fails_without_mutation() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint("platform-partial-token", "platform-partial-boot", 31_334),
|
||
);
|
||
let _session = crate::install_test_platform_session(
|
||
"runner-owner-a",
|
||
"runner-token-a",
|
||
"https://dev.genarrative.world",
|
||
);
|
||
let before = crate::current_platform_session();
|
||
let error = apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-b".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(2),
|
||
platform_auth_revision: Some(2),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect_err("partial platform session must fail closed");
|
||
assert!(error.contains("参数不完整"), "{error}");
|
||
assert_eq!(crate::current_platform_session(), before);
|
||
}
|
||
|
||
#[test]
|
||
fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old_epoch() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint("platform-epoch-token", "platform-epoch-boot", 31_335),
|
||
);
|
||
let _session = crate::install_test_platform_session(
|
||
"runner-owner-seed",
|
||
"runner-token-seed",
|
||
"https://dev.genarrative.world",
|
||
);
|
||
let owner_a = acquire_test_gui_participant(&config_dir, 0);
|
||
let owner_a_epoch = owner_a.owner_epoch.clone();
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner_a_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-a".to_string()),
|
||
platform_access_token: Some("runner-token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(10),
|
||
platform_auth_revision: Some(10),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("old GUI installs high-generation owner A");
|
||
drop(owner_a);
|
||
|
||
let owner_b = acquire_test_gui_participant(&config_dir, 0);
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner_b.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-b".to_string()),
|
||
platform_access_token: Some("runner-token-b".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(1),
|
||
platform_auth_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("new GUI epoch replaces higher-generation old owner");
|
||
assert_eq!(
|
||
crate::current_platform_session()
|
||
.map(|session| (session.user_id, session.identity_generation)),
|
||
Some(("runner-owner-b".to_string(), 1))
|
||
);
|
||
|
||
let stale_error = apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner_a_epoch),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-a".to_string()),
|
||
platform_access_token: Some("runner-token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(11),
|
||
platform_auth_revision: Some(11),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect_err("old GUI epoch must not overwrite the current owner");
|
||
assert!(stale_error.contains("claim 已过期"), "{stale_error}");
|
||
assert_eq!(
|
||
crate::current_platform_session().map(|session| session.user_id),
|
||
Some("runner-owner-b".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_reattach() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let token = "platform-claim-gate-token-platform-claim-gate-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "platform-claim-gate-boot", 31_337),
|
||
);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
let _session = crate::install_test_platform_session(
|
||
"runner-owner-seed",
|
||
"runner-token-seed",
|
||
"https://dev.genarrative.world",
|
||
);
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-a".to_string()),
|
||
platform_access_token: Some("runner-token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(8),
|
||
platform_auth_revision: Some(8),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("attach owner A claim");
|
||
state.gui_owner_attached.store(true, Ordering::Release);
|
||
|
||
write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1)
|
||
.expect("advance durable claim before reattach");
|
||
assert!(
|
||
!external_agent_runner_shutdown_if_gui_owner_lost(&state)
|
||
.expect("watchdog observes a still-live GUI owner"),
|
||
"claim mismatch must isolate the platform session without stopping a live GUI owner"
|
||
);
|
||
assert_eq!(crate::current_platform_session(), None);
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(1),
|
||
platform_user_id: Some("runner-owner-b".to_string()),
|
||
platform_access_token: Some("runner-token-b".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(1),
|
||
platform_auth_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("current durable claim reattaches owner B");
|
||
validate_external_agent_runner_gui_owner_claim_current(&state)
|
||
.expect("reattached owner B claim is current");
|
||
assert_eq!(
|
||
crate::current_platform_session()
|
||
.map(|session| (session.user_id, session.identity_generation)),
|
||
Some(("runner-owner-b".to_string(), 1))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_platform_session_sync_fences_runner_before_returning_error() {
|
||
let fenced = std::cell::Cell::new(false);
|
||
let error = synchronize_external_agent_runner_platform_session_with(
|
||
|| Err("injected platform-session sync failure".to_string()),
|
||
|| {
|
||
fenced.set(true);
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect_err("sync failure must be returned after fencing");
|
||
assert!(fenced.get());
|
||
assert!(error.contains("Agent Runner 已停止"), "{error}");
|
||
|
||
let fence_error = synchronize_external_agent_runner_platform_session_with(
|
||
|| Err("injected platform-session sync failure".to_string()),
|
||
|| Err("injected runner fence failure".to_string()),
|
||
)
|
||
.expect_err("failed fence must report both errors");
|
||
assert!(fence_error.contains("阻断旧账号 Agent Runner 失败"));
|
||
assert!(fence_error.contains("injected runner fence failure"));
|
||
}
|
||
|
||
#[test]
|
||
fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
platform_user_id: Some("runner-owner-a".to_string()),
|
||
platform_access_token: Some("runner-token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(1),
|
||
platform_auth_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register owner A");
|
||
|
||
let fenced = std::cell::Cell::new(false);
|
||
let error = synchronize_external_agent_runner_platform_session_with(
|
||
|| {
|
||
remember_external_agent_runner_platform_session_with(
|
||
&state,
|
||
Some((
|
||
"runner-owner-b",
|
||
"runner-token-b",
|
||
"https://dev.genarrative.world",
|
||
)),
|
||
2,
|
||
2,
|
||
|_, _| Err("injected durable claim write failure".to_string()),
|
||
)
|
||
},
|
||
|| {
|
||
fenced.set(true);
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect_err("claim write failure must fence the Runner");
|
||
assert!(fenced.get());
|
||
assert!(error.contains("durable claim write failure"), "{error}");
|
||
|
||
let endpoint = test_endpoint(
|
||
"claim-write-failure-token-claim-write-failure-token",
|
||
"claim-write-failure-boot",
|
||
31_336,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
assert_eq!(params.platform_user_id.as_deref(), Some("runner-owner-a"));
|
||
assert_eq!(params.platform_auth_generation, Some(1));
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("failed claim write must leave owner A registration intact");
|
||
}
|
||
|
||
#[test]
|
||
fn runner_output_redacts_camel_case_platform_access_token_fields() {
|
||
let config_dir = Path::new("private-appdata");
|
||
assert_eq!(
|
||
sanitize_agent_runner_output(
|
||
r#"request={"platformAccessToken":"must-not-leak"}"#,
|
||
config_dir,
|
||
),
|
||
"<sensitive runner output redacted>"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("retry-gui-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams::default(),
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"gui-owner-retry-token-gui-owner-retry-token",
|
||
"gui-owner-retry-boot",
|
||
31320,
|
||
);
|
||
let attempts = std::cell::Cell::new(0_u32);
|
||
|
||
let error = attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
Err("injected attach failure".to_string())
|
||
},
|
||
)
|
||
.expect_err("failed attach must remain pending");
|
||
assert_eq!(error, "injected attach failure");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("same boot retries after failure");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| panic!("successful retry must mark the boot attached"),
|
||
)
|
||
.expect("successful retry is idempotent");
|
||
assert_eq!(attempts.get(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("missing-sink-confirmation-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_322),
|
||
event_sink_token: Some("c".repeat(64)),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"missing-sink-confirmation-runner-token",
|
||
"missing-sink-confirmation-boot",
|
||
31_322,
|
||
);
|
||
let attempts = std::cell::Cell::new(0_u32);
|
||
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||
"attached": true
|
||
}))
|
||
},
|
||
)
|
||
.expect_err("missing eventSinkAttached must fail");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||
"attached": true,
|
||
"eventSinkAttached": true
|
||
}))
|
||
},
|
||
)
|
||
.expect("same boot retries after missing event sink confirmation");
|
||
assert_eq!(attempts.get(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let config_dir = PathBuf::from("false-sink-confirmation-appdata");
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
&config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_323),
|
||
event_sink_token: Some("d".repeat(64)),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"false-sink-confirmation-runner-token",
|
||
"false-sink-confirmation-boot",
|
||
31_323,
|
||
);
|
||
let attempts = std::cell::Cell::new(0_u32);
|
||
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||
"attached": true,
|
||
"eventSinkAttached": false
|
||
}))
|
||
},
|
||
)
|
||
.expect_err("false eventSinkAttached must fail");
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&config_dir,
|
||
&endpoint,
|
||
|_, _| {
|
||
attempts.set(attempts.get() + 1);
|
||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||
"attached": true,
|
||
"eventSinkAttached": true
|
||
}))
|
||
},
|
||
)
|
||
.expect("same boot retries after false event sink confirmation");
|
||
assert_eq!(attempts.get(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_registration_does_not_cross_config_dirs() {
|
||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
let registered_config_dir = PathBuf::from("registered-gui-appdata");
|
||
let other_config_dir = PathBuf::from("other-gui-appdata");
|
||
let event_sink_token = "e".repeat(64);
|
||
register_external_agent_runner_gui_owner_attachment(
|
||
&state,
|
||
®istered_config_dir,
|
||
ExternalAgentRunnerGuiOwnerClaimMode::Adopt,
|
||
ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_324),
|
||
event_sink_token: Some(event_sink_token.clone()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("register GUI owner attachment");
|
||
let endpoint = test_endpoint(
|
||
"gui-owner-config-token-gui-owner-config-token",
|
||
"gui-owner-config-boot",
|
||
31321,
|
||
);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
&other_config_dir,
|
||
&endpoint,
|
||
|_, _| panic!("GUI owner registration must stay bound to its AppData"),
|
||
)
|
||
.expect("other AppData remains unattached");
|
||
|
||
let calls = std::cell::Cell::new(0_u32);
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&state,
|
||
®istered_config_dir,
|
||
&endpoint,
|
||
|_, params| {
|
||
calls.set(calls.get() + 1);
|
||
assert_eq!(params.event_sink_port, Some(31_324));
|
||
assert_eq!(
|
||
params.event_sink_token.as_deref(),
|
||
Some(event_sink_token.as_str())
|
||
);
|
||
Ok(())
|
||
},
|
||
)
|
||
.expect("registered AppData attaches");
|
||
assert_eq!(calls.get(), 1);
|
||
|
||
let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||
&unregistered,
|
||
®istered_config_dir,
|
||
&endpoint,
|
||
|_, _| panic!("CLI state without GUI registration must not attach"),
|
||
)
|
||
.expect("unregistered CLI state remains unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir);
|
||
assert!(!external_agent_runner_lock_is_held(&participant_lock_path)
|
||
.expect("probe without any window"));
|
||
|
||
let first = acquire_external_agent_runner_gui_participant_lock(&config_dir)
|
||
.expect("first window participates");
|
||
assert!(external_agent_runner_lock_is_held(&participant_lock_path)
|
||
.expect("first window keeps the runner alive"));
|
||
let second = acquire_external_agent_runner_gui_participant_lock(&config_dir)
|
||
.expect("second window shares the same AppData");
|
||
|
||
drop(second);
|
||
assert!(
|
||
external_agent_runner_lock_is_held(&participant_lock_path)
|
||
.expect("remaining window keeps the runner alive"),
|
||
"runner must survive while any window is still open"
|
||
);
|
||
drop(first);
|
||
assert!(
|
||
!external_agent_runner_lock_is_held(&participant_lock_path)
|
||
.expect("last window releases the participant lock"),
|
||
"runner may stop once every window has exited"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let published =
|
||
publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim");
|
||
assert_eq!(published.session_revision, 3);
|
||
|
||
let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9)
|
||
.expect("adopt existing claim");
|
||
assert_eq!(adopted.owner_epoch, published.owner_epoch);
|
||
assert_eq!(
|
||
adopted.session_revision, 3,
|
||
"采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch"
|
||
);
|
||
|
||
let rotated =
|
||
publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim");
|
||
assert_ne!(rotated.owner_epoch, published.owner_epoch);
|
||
assert_eq!(rotated.session_revision, 9);
|
||
assert_eq!(
|
||
read_external_agent_runner_gui_owner_claim(&config_dir)
|
||
.expect("read durable claim")
|
||
.session_revision,
|
||
9
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn second_window_attach_with_same_claim_keeps_runner_platform_session() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"multi-window-claim-token-multi-window-claim-token",
|
||
"multi-window-claim-boot",
|
||
31_338,
|
||
),
|
||
);
|
||
let _session = crate::install_test_platform_session(
|
||
"runner-owner-a",
|
||
"runner-token-a",
|
||
"https://dev.genarrative.world",
|
||
);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
platform_user_id: Some("runner-owner-a".to_string()),
|
||
platform_access_token: Some("runner-token-a".to_string()),
|
||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||
platform_auth_generation: Some(7),
|
||
platform_auth_revision: Some(7),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("first window installs its session");
|
||
assert_eq!(
|
||
crate::current_platform_session()
|
||
.map(|session| (session.user_id, session.identity_generation)),
|
||
Some(("runner-owner-a".to_string(), 7))
|
||
);
|
||
|
||
// 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。
|
||
apply_external_agent_runner_gui_owner_platform_session(
|
||
&state,
|
||
&ExternalAgentRunnerRequestParams {
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
)
|
||
.expect("second window attaches with the same claim");
|
||
assert_eq!(
|
||
crate::current_platform_session()
|
||
.map(|session| (session.user_id, session.identity_generation)),
|
||
Some(("runner-owner-a".to_string(), 7)),
|
||
"同一 claim 的第二个窗口不得清空平台登录态"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() {
|
||
let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
|
||
assert_eq!(sink_guard.configured_sink(), None);
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let token = "gui-owner-monitor-token-gui-owner-monitor-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "gui-owner-monitor-boot", 31319),
|
||
);
|
||
let owner = acquire_test_gui_participant(&config_dir, 0);
|
||
let attached = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "gui-owner-attach-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.attach_gui_owner".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_318),
|
||
event_sink_token: Some("b".repeat(64)),
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
assert!(attached.ok);
|
||
assert!(state.gui_owner_attached.load(Ordering::Acquire));
|
||
assert_eq!(
|
||
sink_guard.configured_sink(),
|
||
Some(crate::GameCreatorManifestInvalidationEventSink {
|
||
port: 31_318,
|
||
token: "b".repeat(64),
|
||
})
|
||
);
|
||
assert!(
|
||
!external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present")
|
||
);
|
||
|
||
write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1)
|
||
.expect("advance owner claim revision");
|
||
let replacement = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "gui-owner-attach-2".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.attach_gui_owner".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_319),
|
||
event_sink_token: Some("c".repeat(64)),
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(1),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
assert!(replacement.ok);
|
||
assert_eq!(
|
||
sink_guard.configured_sinks(),
|
||
vec![
|
||
crate::GameCreatorManifestInvalidationEventSink {
|
||
port: 31_318,
|
||
token: "b".repeat(64),
|
||
},
|
||
crate::GameCreatorManifestInvalidationEventSink {
|
||
port: 31_319,
|
||
token: "c".repeat(64),
|
||
},
|
||
],
|
||
"第二个窗口 attach 必须让两个接收端同时保留"
|
||
);
|
||
|
||
let stale_replay = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "gui-owner-attach-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.attach_gui_owner".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
event_sink_port: Some(31_318),
|
||
event_sink_token: Some("b".repeat(64)),
|
||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||
gui_owner_session_revision: Some(0),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
assert!(!stale_replay.ok);
|
||
assert_eq!(
|
||
stale_replay.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("platform-session-invalid")
|
||
);
|
||
assert_eq!(
|
||
sink_guard.configured_sinks(),
|
||
vec![
|
||
crate::GameCreatorManifestInvalidationEventSink {
|
||
port: 31_318,
|
||
token: "b".repeat(64),
|
||
},
|
||
crate::GameCreatorManifestInvalidationEventSink {
|
||
port: 31_319,
|
||
token: "c".repeat(64),
|
||
},
|
||
],
|
||
"旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端"
|
||
);
|
||
|
||
drop(owner);
|
||
assert!(external_agent_runner_shutdown_if_gui_owner_lost(&state)
|
||
.expect("owner loss requests shutdown"));
|
||
assert!(state.draining.load(Ordering::Acquire));
|
||
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
|
||
assert!(state.shutdown_requested.load(Ordering::Acquire));
|
||
drop(sink_guard);
|
||
let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard();
|
||
assert_eq!(cleanup_guard.configured_sink(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn gui_owner_attach_rejects_missing_owner_lock() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = private_runner_test_config_dir(&directory);
|
||
let token = "gui-owner-missing-token-gui-owner-missing-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "gui-owner-missing-boot", 31320),
|
||
);
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "gui-owner-attach-missing-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.attach_gui_owner".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("gui-owner-missing")
|
||
);
|
||
assert!(!state.gui_owner_attached.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn framing_round_trips_length_prefixed_json() {
|
||
let payload = br#"{"method":"runner.ping","requestId":"request-1"}"#;
|
||
let mut framed = Vec::new();
|
||
write_external_agent_runner_frame(&mut framed, payload).expect("write frame");
|
||
|
||
assert_eq!(
|
||
&framed[..4],
|
||
&(payload.len() as u32).to_be_bytes(),
|
||
"frame prefix uses network byte order"
|
||
);
|
||
let decoded =
|
||
read_external_agent_runner_frame(&mut Cursor::new(framed)).expect("read framed payload");
|
||
assert_eq!(decoded, payload);
|
||
}
|
||
|
||
#[test]
|
||
fn framing_rejects_oversize_before_reading_payload() {
|
||
let declared = (EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES as u32) + 1;
|
||
let error = read_external_agent_runner_frame(&mut Cursor::new(declared.to_be_bytes()))
|
||
.expect_err("oversize frame must fail");
|
||
assert!(matches!(
|
||
error,
|
||
ExternalAgentRunnerFrameError::Oversize(value) if value == declared
|
||
));
|
||
|
||
let payload = vec![0_u8; EXTERNAL_AGENT_RUNNER_MAX_FRAME_BYTES + 1];
|
||
let error = write_external_agent_runner_frame(&mut Vec::new(), &payload)
|
||
.expect_err("oversize response must fail");
|
||
assert!(matches!(error, ExternalAgentRunnerFrameError::Oversize(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn authentication_rejects_wrong_token_without_echoing_secrets() {
|
||
let directory = unique_test_directory();
|
||
let endpoint = test_endpoint(
|
||
"correct-private-token-correct-private-token",
|
||
"test-boot-id",
|
||
12345,
|
||
);
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
endpoint,
|
||
);
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "auth-request-1".to_string(),
|
||
token: "wrong-private-token-wrong-private-token".to_string(),
|
||
method: "runner.ping".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("unauthorized")
|
||
);
|
||
let serialized = serde_json::to_string(&response).expect("serialize auth response");
|
||
assert!(!serialized.contains("correct-private-token"));
|
||
assert!(!serialized.contains("wrong-private-token"));
|
||
assert!(!serialized.contains("\"token\""));
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_error_redaction_hides_project_and_absolute_paths_from_runner_clients() {
|
||
let directory = unique_test_directory();
|
||
let token = "runner-private-token-runner-private-token";
|
||
let failing_path = directory
|
||
.0
|
||
.join(".agent/runtime/tool-plan-handoffs/broken-ledger.json");
|
||
let error = format!(
|
||
"读取 tool-plan 成功响应交接失败:{};token={token};backup=/home/private/ledger.previous",
|
||
failing_path.display()
|
||
);
|
||
|
||
let redacted = redact_external_agent_runner_runtime_error(&directory.0, &error, token);
|
||
assert!(!redacted.contains(directory.0.to_string_lossy().as_ref()));
|
||
assert!(!redacted.contains(token));
|
||
assert!(!redacted.contains("/home/private"));
|
||
assert!(redacted.contains("$PROJECT_ROOT"));
|
||
assert!(redacted.contains("<absolute-path>"));
|
||
}
|
||
|
||
#[test]
|
||
fn continuation_params_bind_agent_run_and_action_exactly() {
|
||
let request = ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "continue-exact-1".to_string(),
|
||
token: "continue-private-token-continue-private-token".to_string(),
|
||
method: "runtime.continue_action".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some("/tmp/exact-project".to_string()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-exact-7".to_string()),
|
||
action_id: Some("action-exact-9".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
external_agent_runner_request_agent(&request).as_deref(),
|
||
Ok("code-prototype")
|
||
);
|
||
assert_eq!(
|
||
external_agent_runner_request_run_id(&request).as_deref(),
|
||
Ok("run-exact-7")
|
||
);
|
||
assert_eq!(
|
||
external_agent_runner_request_action_id(&request).as_deref(),
|
||
Ok("action-exact-9")
|
||
);
|
||
let wire = serde_json::to_value(&request).expect("serialize exact continuation");
|
||
assert_eq!(wire["params"]["runId"], "run-exact-7");
|
||
assert_eq!(wire["params"]["actionId"], "action-exact-9");
|
||
}
|
||
|
||
#[test]
|
||
fn project_supervisor_targeted_wake_requires_exact_progress_or_terminal_state() {
|
||
let probe = |agent_id: &str, run_id: &str, status: &str, phase: &str| {
|
||
ExternalAgentRunnerTargetRunProbe {
|
||
agent_id: agent_id.to_string(),
|
||
run_id: run_id.to_string(),
|
||
status: status.to_string(),
|
||
phase: phase.to_string(),
|
||
}
|
||
};
|
||
let agent_id = "project-supervisor";
|
||
let run_id = "supervisor-parent-run-1";
|
||
let waiting = probe(agent_id, run_id, "running", "waiting-for-delegate-receipts");
|
||
let provider_retry_waiting = probe(agent_id, run_id, "running", "waiting-for-provider-retry");
|
||
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&waiting)),
|
||
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
|
||
"a busy target lane must not turn an empty global scan into success"
|
||
);
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(
|
||
agent_id,
|
||
run_id,
|
||
&[probe(
|
||
"design-foundation",
|
||
"child-run-1",
|
||
"running",
|
||
"planning"
|
||
)],
|
||
None,
|
||
),
|
||
Err(ExternalAgentRunnerTargetWakeRetry::NotObserved),
|
||
"advancing another lane must not acknowledge the target run"
|
||
);
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(
|
||
agent_id,
|
||
run_id,
|
||
std::slice::from_ref(&waiting),
|
||
Some(&waiting),
|
||
),
|
||
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
|
||
"observing the target without advancing it remains retryable"
|
||
);
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(
|
||
agent_id,
|
||
run_id,
|
||
std::slice::from_ref(&provider_retry_waiting),
|
||
Some(&provider_retry_waiting),
|
||
),
|
||
Err(ExternalAgentRunnerTargetWakeRetry::StillPending),
|
||
"a durable Provider retry wait remains retryable until the target advances"
|
||
);
|
||
|
||
let advanced = probe(agent_id, run_id, "running", "planning");
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(
|
||
agent_id,
|
||
run_id,
|
||
std::slice::from_ref(&advanced),
|
||
Some(&waiting),
|
||
),
|
||
Ok(()),
|
||
"the exact target may be acknowledged after the scan advances it"
|
||
);
|
||
let completed = probe(agent_id, run_id, "completed", "completed");
|
||
assert_eq!(
|
||
classify_external_agent_runner_target_wake(agent_id, run_id, &[], Some(&completed)),
|
||
Ok(()),
|
||
"a terminal target no longer needs wake processing"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn project_supervisor_retryable_targeted_wake_is_not_cached_before_success() {
|
||
let request_id = "runtime-parent-wake-stable-1";
|
||
let fingerprint = "stable-targeted-wake-fingerprint";
|
||
let mut cache = ExternalAgentRunnerRequestCache::default();
|
||
|
||
for retry in [
|
||
ExternalAgentRunnerTargetWakeRetry::NotObserved,
|
||
ExternalAgentRunnerTargetWakeRetry::StillPending,
|
||
] {
|
||
let response = external_agent_runner_target_wake_retryable_response(request_id, retry);
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some(EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE)
|
||
);
|
||
cache_external_agent_runner_response_if_cacheable(
|
||
&mut cache,
|
||
request_id,
|
||
fingerprint,
|
||
&response,
|
||
);
|
||
assert!(
|
||
cache.find(request_id).is_none(),
|
||
"retryable wake must leave the stable requestId free for another attempt"
|
||
);
|
||
}
|
||
|
||
let success = ExternalAgentRunnerResponse::success(request_id, json!({ "accepted": true }));
|
||
cache_external_agent_runner_response_if_cacheable(
|
||
&mut cache,
|
||
request_id,
|
||
fingerprint,
|
||
&success,
|
||
);
|
||
assert_eq!(
|
||
cache.find(request_id).map(|cached| &cached.response),
|
||
Some(&success),
|
||
"the stable requestId becomes cacheable only after wake is satisfied"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn steer_params_bind_identity_without_instruction_body() {
|
||
let instruction = "把角色移动速度改快一些";
|
||
let request = ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "steer-wire-1".to_string(),
|
||
token: "steer-private-token-steer-private-token".to_string(),
|
||
method: "runtime.steer".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some("/tmp/steer-project".to_string()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-steer-7".to_string()),
|
||
steer_id: Some("steer-9".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
external_agent_runner_request_steer_id(&request).as_deref(),
|
||
Ok("steer-9")
|
||
);
|
||
let wire = serde_json::to_value(&request).expect("serialize steer request");
|
||
let params = wire["params"].as_object().expect("steer params object");
|
||
assert_eq!(params.len(), 4);
|
||
assert_eq!(
|
||
params.get("root").and_then(Value::as_str),
|
||
Some("/tmp/steer-project")
|
||
);
|
||
assert_eq!(
|
||
params.get("agent").and_then(Value::as_str),
|
||
Some("code-prototype")
|
||
);
|
||
assert_eq!(
|
||
params.get("runId").and_then(Value::as_str),
|
||
Some("run-steer-7")
|
||
);
|
||
assert_eq!(
|
||
params.get("steerId").and_then(Value::as_str),
|
||
Some("steer-9")
|
||
);
|
||
let wire = serde_json::to_string(&wire).expect("serialize steer wire value");
|
||
assert!(!wire.contains(instruction));
|
||
assert!(!wire.contains("instruction"));
|
||
assert!(!wire.contains("content"));
|
||
}
|
||
|
||
#[test]
|
||
fn typed_steer_result_requires_provider_interrupted_boolean() {
|
||
assert_eq!(
|
||
parse_external_agent_runner_steer_result(&json!({
|
||
"providerInterrupted": true,
|
||
})),
|
||
Ok(true)
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_steer_result(&json!({
|
||
"providerInterrupted": false,
|
||
})),
|
||
Ok(false)
|
||
);
|
||
assert!(parse_external_agent_runner_steer_result(&json!({ "accepted": true })).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_steer_queues_without_interrupt_and_deduplicates_request_id() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-steer-rpc", "Runner steer 测试")
|
||
.expect("initialize steer project");
|
||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&root,
|
||
"code-prototype",
|
||
None,
|
||
"实现一个可验证的键盘操作原型",
|
||
"run-steer-rpc",
|
||
"agent-background-task",
|
||
"准备规划实现步骤",
|
||
vec!["读取项目".to_string(), "实现并验证".to_string()],
|
||
)
|
||
.expect("start steer runtime");
|
||
let persisted = crate::steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
&runtime.session_id,
|
||
"run-steer-rpc",
|
||
"steer-rpc-1",
|
||
"先停下当前方案,改用键盘操作。",
|
||
"runner-test",
|
||
)
|
||
.expect("persist steer before runner notification");
|
||
assert_eq!(persisted.steer_id, "steer-rpc-1");
|
||
let appdata = directory.0.join("appdata");
|
||
fs::create_dir_all(&appdata).expect("create runner appdata");
|
||
let token = "steer-rpc-private-token-steer-rpc-private-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "steer-rpc-boot", 30303),
|
||
);
|
||
let (provider_key, active_provider) =
|
||
crate::register_game_creator_agent_runtime_provider_request(
|
||
&root,
|
||
"code-prototype",
|
||
"run-steer-rpc",
|
||
0,
|
||
)
|
||
.expect("register active Provider before runtime.steer");
|
||
let request = ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "steer-rpc-request-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.steer".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-steer-rpc".to_string()),
|
||
steer_id: Some("steer-rpc-1".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
};
|
||
|
||
let first = dispatch_external_agent_runner_runtime_request(&request, &state);
|
||
assert!(first.ok, "runtime.steer failed: {:?}", first.error);
|
||
assert_eq!(
|
||
first
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(
|
||
!active_provider.interrupted.load(Ordering::Acquire),
|
||
"runtime.steer must not interrupt an active Provider before the LLM decision"
|
||
);
|
||
|
||
let replay = dispatch_external_agent_runner_runtime_request(&request, &state);
|
||
assert_eq!(replay, first);
|
||
assert!(!active_provider.interrupted.load(Ordering::Acquire));
|
||
|
||
let mut conflict = request;
|
||
conflict.params.steer_id = Some("steer-rpc-2".to_string());
|
||
let conflict = dispatch_external_agent_runner_runtime_request(&conflict, &state);
|
||
assert!(!conflict.ok);
|
||
assert_eq!(
|
||
conflict.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("request-id-conflict")
|
||
);
|
||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_interrupt_for_steer_decision_rejects_missing_durable_decision() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-steer-missing-decision-rpc",
|
||
"Runner steer 缺少判定测试",
|
||
)
|
||
.expect("initialize missing-decision project");
|
||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&root,
|
||
"code-prototype",
|
||
None,
|
||
"保持当前 Provider 请求运行",
|
||
"run-steer-missing-decision-rpc",
|
||
"agent-background-task",
|
||
"等待 Supervisor 判定",
|
||
vec!["完成当前任务".to_string()],
|
||
)
|
||
.expect("start missing-decision runtime");
|
||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.session_id,
|
||
&runtime.run_id,
|
||
"steer-missing-decision-rpc-1",
|
||
"先告诉我进度。",
|
||
"runner-test",
|
||
)
|
||
.expect("persist steer without decision");
|
||
assert_eq!(steer.sequence, 1);
|
||
let (provider_key, active_provider) =
|
||
crate::register_game_creator_agent_runtime_provider_request(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
0,
|
||
)
|
||
.expect("register active Provider before missing decision request");
|
||
let token = "steer-missing-decision-token-steer-missing-decision-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "steer-missing-decision-boot", 30304),
|
||
);
|
||
let response = dispatch_external_agent_runner_runtime_request(
|
||
&ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "steer-missing-decision-request-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string_lossy().into_owned()),
|
||
agent: Some(runtime.agent_id.clone()),
|
||
run_id: Some(runtime.run_id.clone()),
|
||
steer_id: Some(steer.steer_id),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("runtime-error")
|
||
);
|
||
assert!(response
|
||
.error
|
||
.as_ref()
|
||
.is_some_and(|error| error.message.contains("尚无 LLM 中断判定")));
|
||
assert!(
|
||
!active_provider.interrupted.load(Ordering::Acquire),
|
||
"a missing durable LLM decision must leave the active Provider running"
|
||
);
|
||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_interrupt_for_false_steer_decision_keeps_provider_running() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-steer-false-decision-rpc",
|
||
"Runner steer 不中断判定测试",
|
||
)
|
||
.expect("initialize false-decision project");
|
||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&root,
|
||
"code-prototype",
|
||
None,
|
||
"保持当前 Provider 请求运行",
|
||
"run-steer-false-decision-rpc",
|
||
"agent-background-task",
|
||
"处理状态询问",
|
||
vec!["完成当前任务".to_string()],
|
||
)
|
||
.expect("start false-decision runtime");
|
||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.session_id,
|
||
&runtime.run_id,
|
||
"steer-false-decision-rpc-1",
|
||
"现在做到哪一步了?",
|
||
"runner-test",
|
||
)
|
||
.expect("persist status-question steer");
|
||
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||
&root,
|
||
&runtime,
|
||
&steer.steer_id,
|
||
steer.sequence,
|
||
crate::AgentRuntimeSteerDecision {
|
||
reply: "当前任务仍在继续,我会按现有方向完成。".to_string(),
|
||
interrupt_current_provider: false,
|
||
reason: "状态询问不会让当前方案过期。".to_string(),
|
||
},
|
||
)
|
||
.expect("persist non-interrupting LLM decision");
|
||
let (provider_key, active_provider) =
|
||
crate::register_game_creator_agent_runtime_provider_request(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
0,
|
||
)
|
||
.expect("register active Provider for false decision");
|
||
let token = "steer-false-decision-token-steer-false-decision-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "steer-false-decision-boot", 30305),
|
||
);
|
||
let response = dispatch_external_agent_runner_runtime_request(
|
||
&ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "steer-false-decision-request-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string_lossy().into_owned()),
|
||
agent: Some(runtime.agent_id.clone()),
|
||
run_id: Some(runtime.run_id.clone()),
|
||
steer_id: Some(steer.steer_id),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(
|
||
response.ok,
|
||
"false decision RPC failed: {:?}",
|
||
response.error
|
||
);
|
||
assert_eq!(
|
||
response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!active_provider.interrupted.load(Ordering::Acquire));
|
||
crate::unregister_game_creator_agent_runtime_provider_request(&provider_key, &active_provider);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-steer-true-decision-rpc",
|
||
"Runner steer 条件中断测试",
|
||
)
|
||
.expect("initialize true-decision project");
|
||
let runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&root,
|
||
"code-prototype",
|
||
None,
|
||
"按当前方向实现玩法",
|
||
"run-steer-true-decision-rpc",
|
||
"agent-background-task",
|
||
"生成当前方案",
|
||
vec!["完成当前任务".to_string()],
|
||
)
|
||
.expect("start true-decision runtime");
|
||
let steer = crate::steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.session_id,
|
||
&runtime.run_id,
|
||
"steer-true-decision-rpc-1",
|
||
"停止旧方向,改成全新的玩法。",
|
||
"runner-test",
|
||
)
|
||
.expect("persist conflicting steer");
|
||
crate::persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||
&root,
|
||
&runtime,
|
||
&steer.steer_id,
|
||
steer.sequence,
|
||
crate::AgentRuntimeSteerDecision {
|
||
reply: "这个改动会让旧方案过期,我会安全切换方向。".to_string(),
|
||
interrupt_current_provider: true,
|
||
reason: "当前 Provider 正在生成已经冲突的旧方案。".to_string(),
|
||
},
|
||
)
|
||
.expect("persist interrupting LLM decision");
|
||
let token = "steer-true-decision-token-steer-true-decision-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "steer-true-decision-boot", 30306),
|
||
);
|
||
let request = |request_id: &str| ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: request_id.to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.interrupt_for_steer_decision".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string_lossy().into_owned()),
|
||
agent: Some(runtime.agent_id.clone()),
|
||
run_id: Some(runtime.run_id.clone()),
|
||
steer_id: Some(steer.steer_id.clone()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
};
|
||
|
||
let (old_provider_key, old_provider) =
|
||
crate::register_game_creator_agent_runtime_provider_request(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
steer.sequence.saturating_sub(1),
|
||
)
|
||
.expect("register Provider created before steer");
|
||
let old_response = dispatch_external_agent_runner_runtime_request(
|
||
&request("steer-true-decision-old-provider-request"),
|
||
&state,
|
||
);
|
||
assert!(
|
||
old_response.ok,
|
||
"old Provider interrupt RPC failed: {:?}",
|
||
old_response.error
|
||
);
|
||
assert_eq!(
|
||
old_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(old_provider.interrupted.load(Ordering::Acquire));
|
||
crate::unregister_game_creator_agent_runtime_provider_request(&old_provider_key, &old_provider);
|
||
|
||
let (new_provider_key, new_provider) =
|
||
crate::register_game_creator_agent_runtime_provider_request(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
steer.sequence,
|
||
)
|
||
.expect("register Provider created after steer was applied");
|
||
let new_response = dispatch_external_agent_runner_runtime_request(
|
||
&request("steer-true-decision-new-provider-request"),
|
||
&state,
|
||
);
|
||
assert!(
|
||
new_response.ok,
|
||
"new Provider keep-running RPC failed: {:?}",
|
||
new_response.error
|
||
);
|
||
assert_eq!(
|
||
new_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(
|
||
!new_provider.interrupted.load(Ordering::Acquire),
|
||
"a Provider created at the decided steer cursor must keep running"
|
||
);
|
||
crate::unregister_game_creator_agent_runtime_provider_request(&new_provider_key, &new_provider);
|
||
}
|
||
|
||
#[test]
|
||
fn typed_goal_pause_and_cancel_require_durable_intent_and_keep_exact_run() {
|
||
let pause_directory = unique_test_directory();
|
||
let pause_root = pause_directory.0.join("pause-project");
|
||
crate::init_local_game_project_at(
|
||
&pause_root,
|
||
"project-goal-pause-rpc",
|
||
"Runner Goal pause 测试",
|
||
)
|
||
.expect("initialize Goal pause project");
|
||
let mut pause_runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&pause_root,
|
||
"code-prototype",
|
||
None,
|
||
"暂停同一 Goal run",
|
||
"run-goal-pause-rpc",
|
||
"agent-background-task",
|
||
"等待暂停",
|
||
vec!["保持同一 run".to_string()],
|
||
)
|
||
.expect("start Goal pause runtime");
|
||
let pause_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at(
|
||
&pause_root,
|
||
&mut pause_runtime,
|
||
"暂停后继续同一 run",
|
||
crate::AGENT_GOAL_STATUS_PAUSE_REQUESTED,
|
||
)
|
||
.expect("seed pause-requested Goal");
|
||
let pause_token = "goal-pause-rpc-token-goal-pause-rpc-token";
|
||
let pause_state = ExternalAgentRunnerServerState::new(
|
||
pause_directory
|
||
.0
|
||
.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(pause_token, "goal-pause-rpc-boot", 30313),
|
||
);
|
||
let pause_response = dispatch_external_agent_runner_runtime_request(
|
||
&ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "goal-pause-rpc-request".to_string(),
|
||
token: pause_token.to_string(),
|
||
method: "runtime.pause".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(pause_root.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-goal-pause-rpc".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&pause_state,
|
||
);
|
||
assert!(
|
||
pause_response.ok,
|
||
"runtime.pause failed: {:?}",
|
||
pause_response.error
|
||
);
|
||
assert_eq!(
|
||
pause_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
let paused = crate::read_game_creator_agent_runtime_at(&pause_root, "code-prototype")
|
||
.expect("read paused Goal runtime")
|
||
.state;
|
||
assert_eq!(paused.run_id, pause_goal.run_id);
|
||
assert_eq!(paused.status, "paused");
|
||
assert_eq!(
|
||
paused.goal_status.as_deref(),
|
||
Some(crate::AGENT_GOAL_STATUS_PAUSED)
|
||
);
|
||
|
||
let cancel_directory = unique_test_directory();
|
||
let cancel_root = cancel_directory.0.join("cancel-project");
|
||
crate::init_local_game_project_at(
|
||
&cancel_root,
|
||
"project-goal-cancel-rpc",
|
||
"Runner Goal cancel 测试",
|
||
)
|
||
.expect("initialize Goal cancel project");
|
||
let mut cancel_runtime = crate::start_game_creator_agent_runtime_task_for_session_at(
|
||
&cancel_root,
|
||
"code-prototype",
|
||
None,
|
||
"清理同一 Goal run",
|
||
"run-goal-cancel-rpc",
|
||
"agent-background-task",
|
||
"等待清理",
|
||
vec!["清理同一 run".to_string()],
|
||
)
|
||
.expect("start Goal cancel runtime");
|
||
let cancel_goal = crate::seed_game_creator_agent_goal_for_runtime_test_at(
|
||
&cancel_root,
|
||
&mut cancel_runtime,
|
||
"清理当前 Goal",
|
||
crate::AGENT_GOAL_STATUS_CLEARING,
|
||
)
|
||
.expect("seed clearing Goal");
|
||
let cancel_token = "goal-cancel-rpc-token-goal-cancel-rpc-token";
|
||
let cancel_state = ExternalAgentRunnerServerState::new(
|
||
cancel_directory
|
||
.0
|
||
.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(cancel_token, "goal-cancel-rpc-boot", 30314),
|
||
);
|
||
let cancel_response = dispatch_external_agent_runner_runtime_request(
|
||
&ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "goal-cancel-rpc-request".to_string(),
|
||
token: cancel_token.to_string(),
|
||
method: "runtime.cancel".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(cancel_root.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-goal-cancel-rpc".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&cancel_state,
|
||
);
|
||
assert!(
|
||
cancel_response.ok,
|
||
"runtime.cancel failed: {:?}",
|
||
cancel_response.error
|
||
);
|
||
assert_eq!(
|
||
cancel_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["providerInterrupted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
let cancelled = crate::read_game_creator_agent_runtime_at(&cancel_root, "code-prototype")
|
||
.expect("read cancelled Goal runtime")
|
||
.state;
|
||
assert_eq!(cancelled.run_id, cancel_goal.run_id);
|
||
assert_eq!(cancelled.status, "cancelled");
|
||
let cleared = crate::read_game_creator_agent_goal_at(
|
||
&cancel_root,
|
||
"code-prototype",
|
||
&cancel_goal.session_id,
|
||
)
|
||
.expect("read cleared Goal")
|
||
.expect("cleared Goal exists");
|
||
assert_eq!(cleared.status, crate::AGENT_GOAL_STATUS_CLEARED);
|
||
}
|
||
|
||
#[test]
|
||
fn draining_rejects_runtime_steer_and_compact() {
|
||
let directory = unique_test_directory();
|
||
let token = "steer-draining-token-steer-draining-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "steer-draining-boot", 31312),
|
||
);
|
||
state.draining.store(true, Ordering::Release);
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "steer-draining-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.steer".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(directory.0.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-steer-draining".to_string()),
|
||
steer_id: Some("steer-draining".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("runner-draining")
|
||
);
|
||
|
||
let compact_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "draining-compact-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.compact".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(directory.0.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
session_id: Some("agent-session-code-prototype".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
assert!(!compact_response.ok);
|
||
assert_eq!(
|
||
compact_response
|
||
.error
|
||
.as_ref()
|
||
.map(|error| error.code.as_str()),
|
||
Some("runner-draining")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn draining_rejects_new_runtime_writes() {
|
||
let directory = unique_test_directory();
|
||
let token = "draining-private-token-draining-private-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "draining-boot-id", 31313),
|
||
);
|
||
state.draining.store(true, Ordering::Release);
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "draining-write-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.continue_action".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(directory.0.to_string_lossy().into_owned()),
|
||
agent: Some("code-prototype".to_string()),
|
||
run_id: Some("run-draining".to_string()),
|
||
action_id: Some("action-draining".to_string()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(!response.ok);
|
||
assert_eq!(
|
||
response.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("runner-draining")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn durable_pending_action_prevents_shutdown_and_reopens_writes() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-1.json");
|
||
fs::create_dir_all(pending.parent().expect("pending parent"))
|
||
.expect("create pending directory");
|
||
fs::write(&pending, b"{}").expect("write pending action");
|
||
let token = "shutdown-private-token-shutdown-private-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "shutdown-boot-id", 32323),
|
||
);
|
||
state.remember_root(&root);
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-pending-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(response.ok);
|
||
assert_eq!(
|
||
response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn forced_shutdown_is_accepted_even_when_runtime_is_busy() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-1.json");
|
||
fs::create_dir_all(pending.parent().expect("pending parent"))
|
||
.expect("create pending directory");
|
||
fs::write(&pending, b"{}").expect("write pending action");
|
||
let token = "forced-shutdown-token-forced-shutdown-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "forced-shutdown-boot", 32326),
|
||
);
|
||
state.remember_root(&root);
|
||
state.active_connections.store(8, Ordering::Release);
|
||
|
||
let response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "forced-shutdown-busy-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(response.ok);
|
||
assert_eq!(
|
||
response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["willShutdown"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(state.draining.load(Ordering::Acquire));
|
||
assert!(state.force_shutdown_requested.load(Ordering::Acquire));
|
||
assert!(state.shutdown_requested.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json");
|
||
fs::create_dir_all(pending.parent().expect("pending parent"))
|
||
.expect("create pending directory");
|
||
let durable_bytes = br#"{"durable":true}"#;
|
||
fs::write(&pending, durable_bytes).expect("write pending action");
|
||
let token = "client-exit-private-token-client-exit-private-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "client-exit-boot-id", 32326),
|
||
);
|
||
state.remember_root(&root);
|
||
|
||
let unauthorized_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-client-exit-unauthorized".to_string(),
|
||
token: "wrong-client-exit-private-token".to_string(),
|
||
method: "runner.shutdown_for_client_exit".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(!unauthorized_response.ok);
|
||
assert_eq!(
|
||
unauthorized_response
|
||
.error
|
||
.as_ref()
|
||
.map(|error| error.code.as_str()),
|
||
Some("unauthorized")
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
assert_eq!(
|
||
fs::read(&pending).expect("read pending action after rejected shutdown"),
|
||
durable_bytes
|
||
);
|
||
|
||
let idle_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-client-exit-idle-check".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(idle_response.ok);
|
||
assert_eq!(
|
||
idle_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
|
||
let busy_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-client-exit-busy-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_for_client_exit".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(busy_response.ok);
|
||
assert_eq!(
|
||
busy_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["accepted"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert_eq!(
|
||
busy_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["busy"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert_eq!(
|
||
busy_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["willShutdown"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
assert_eq!(
|
||
fs::read(&pending).expect("read pending action"),
|
||
durable_bytes
|
||
);
|
||
|
||
fs::remove_file(&pending).expect("clear pending action before idle client exit");
|
||
let shutdown_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-client-exit-idle-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_for_client_exit".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(shutdown_response.ok);
|
||
assert_eq!(
|
||
shutdown_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["accepted"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert_eq!(
|
||
shutdown_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["busy"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert_eq!(
|
||
shutdown_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["willShutdown"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(state.draining.load(Ordering::Acquire));
|
||
|
||
let repeated_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-client-exit-force-2".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_for_client_exit".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
assert!(repeated_response.ok);
|
||
assert_eq!(
|
||
repeated_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["accepted"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert_eq!(
|
||
repeated_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["busy"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert_eq!(
|
||
repeated_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["willShutdown"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(!pending.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let handoff_path = root
|
||
.join(".agent/runtime/tool-plan-handoffs")
|
||
.join("agent-key")
|
||
.join("run-key.json");
|
||
fs::create_dir_all(handoff_path.parent().expect("tool-plan handoff parent"))
|
||
.expect("create tool-plan handoff directory");
|
||
fs::write(&handoff_path, b"{").expect("write corrupt tool-plan handoff");
|
||
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff"));
|
||
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path);
|
||
fs::rename(&handoff_path, &previous_path).expect("move tool-plan handoff to previous");
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff"));
|
||
|
||
fs::remove_file(previous_path).expect("remove tool-plan handoff previous");
|
||
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
|
||
}
|
||
|
||
#[test]
|
||
fn durable_provider_retry_prevents_shutdown_and_reopens_writes() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let identity = crate::provider_retry::AgentRuntimeProviderRetryIdentity {
|
||
project_id: "project-provider-retry-idle".to_string(),
|
||
agent_id: "code-prototype".to_string(),
|
||
task_id: "task-provider-retry-idle".to_string(),
|
||
session_id: "session-provider-retry-idle".to_string(),
|
||
run_id: "run-provider-retry-idle".to_string(),
|
||
source: "agent-chat".to_string(),
|
||
goal_id: None,
|
||
goal_revision: 0,
|
||
goal_snapshot_fingerprint: String::new(),
|
||
applied_steer_cursor: 0,
|
||
request_kind: "tool-plan".to_string(),
|
||
base_request_slot: "loop-0-repair-0".to_string(),
|
||
request_fingerprint: "a".repeat(64),
|
||
provider_config_fingerprint: "b".repeat(64),
|
||
web_search_enabled: false,
|
||
allow_idle_context_compaction: false,
|
||
};
|
||
let retry = crate::provider_retry::write_next_at(
|
||
&root,
|
||
&identity,
|
||
"loop-0-repair-0-transient-1",
|
||
1,
|
||
3,
|
||
250,
|
||
"timeout",
|
||
&"c".repeat(64),
|
||
)
|
||
.expect("write durable Provider retry");
|
||
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary retry"));
|
||
let retry_path =
|
||
root.join(".agent/runtime/provider-retries/code-prototype/run-provider-retry-idle.json");
|
||
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&retry_path);
|
||
fs::rename(&retry_path, &previous_path).expect("move Provider retry to previous");
|
||
assert_eq!(
|
||
crate::provider_retry::list_at(&root).expect("scan previous Provider retry"),
|
||
vec![retry]
|
||
);
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous retry"));
|
||
|
||
let token = "provider-retry-shutdown-token-provider-retry-shutdown-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "provider-retry-shutdown-boot", 32324),
|
||
);
|
||
state.remember_root(&root);
|
||
let busy_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-provider-retry-busy-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(busy_response.ok);
|
||
assert_eq!(
|
||
busy_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
|
||
crate::provider_retry::remove_at(&root, &identity.agent_id, &identity.run_id)
|
||
.expect("remove durable Provider retry");
|
||
assert!(crate::provider_retry::list_at(&root)
|
||
.expect("scan removed Provider retries")
|
||
.is_empty());
|
||
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
|
||
|
||
let idle_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-provider-retry-idle-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(idle_response.ok);
|
||
assert_eq!(
|
||
idle_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(state.draining.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
let identity = crate::provider_retry::AgentRuntimeProviderRetryIdentity {
|
||
project_id: "project-provider-handoff-idle".to_string(),
|
||
agent_id: "code-prototype".to_string(),
|
||
task_id: "provider-handoff-task-idle".to_string(),
|
||
session_id: "session-provider-handoff-idle".to_string(),
|
||
run_id: "run-provider-handoff-idle".to_string(),
|
||
source: "agent-chat".to_string(),
|
||
goal_id: None,
|
||
goal_revision: 0,
|
||
goal_snapshot_fingerprint: String::new(),
|
||
applied_steer_cursor: 0,
|
||
request_kind: "final-reply".to_string(),
|
||
base_request_slot: "final-reply-loop-1-revision-0".to_string(),
|
||
request_fingerprint: "d".repeat(64),
|
||
provider_config_fingerprint: "e".repeat(64),
|
||
web_search_enabled: false,
|
||
allow_idle_context_compaction: false,
|
||
};
|
||
let response = platform_llm::LlmRunResponse {
|
||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||
model: "provider-handoff-runner-test".to_string(),
|
||
text: "durable final reply".to_string(),
|
||
reasoning: String::new(),
|
||
finish_reason: Some("stop".to_string()),
|
||
response_id: Some("provider-handoff-response".to_string()),
|
||
usage: None,
|
||
tool_calls: Vec::new(),
|
||
responses_output: Vec::new(),
|
||
};
|
||
let provider_request_id = format!("provider-request-{}", "f".repeat(64));
|
||
crate::provider_handoff::write_at(
|
||
&root,
|
||
&identity,
|
||
&identity.base_request_slot,
|
||
0,
|
||
&provider_request_id,
|
||
&response,
|
||
)
|
||
.expect("write durable Provider handoff");
|
||
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff"));
|
||
let agent_key = format!("{:x}", Sha256::digest(identity.agent_id.as_bytes()));
|
||
let run_key = format!("{:x}", Sha256::digest(identity.run_id.as_bytes()));
|
||
let handoff_path = root
|
||
.join(".agent/runtime/provider-handoffs")
|
||
.join(agent_key)
|
||
.join(format!("{run_key}.json"));
|
||
let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path);
|
||
fs::rename(&handoff_path, &previous_path).expect("move Provider handoff to previous");
|
||
assert_eq!(
|
||
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id,)
|
||
.expect("recover previous Provider handoff")
|
||
.map(|record| record.to_llm_response()),
|
||
Some(response)
|
||
);
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff"));
|
||
|
||
fs::write(&previous_path, b"{").expect("corrupt Provider handoff");
|
||
crate::provider_handoff::read_for_run_at(&root, &identity.agent_id, &identity.run_id)
|
||
.expect_err("corrupt Provider handoff must enter recovery error handling");
|
||
assert!(!external_agent_runner_root_is_idle(&root).expect("scan corrupt handoff"));
|
||
|
||
let token = "provider-handoff-shutdown-token-provider-handoff-shutdown-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
directory.0.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "provider-handoff-shutdown-boot", 32325),
|
||
);
|
||
state.remember_root(&root);
|
||
let busy_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-provider-handoff-busy-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(busy_response.ok);
|
||
assert_eq!(
|
||
busy_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(false)
|
||
);
|
||
assert!(!state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(!state.draining.load(Ordering::Acquire));
|
||
|
||
crate::provider_handoff::remove_at(&root, &identity.agent_id, &identity.run_id)
|
||
.expect("remove corrupt Provider handoff");
|
||
assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root"));
|
||
|
||
let idle_response = handle_external_agent_runner_request(
|
||
ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "shutdown-provider-handoff-idle-1".to_string(),
|
||
token: token.to_string(),
|
||
method: "runner.shutdown_if_idle".to_string(),
|
||
params: ExternalAgentRunnerRequestParams::default(),
|
||
},
|
||
&state,
|
||
);
|
||
|
||
assert!(idle_response.ok);
|
||
assert_eq!(
|
||
idle_response
|
||
.result
|
||
.as_ref()
|
||
.and_then(|value| value["idle"].as_bool()),
|
||
Some(true)
|
||
);
|
||
assert!(state.shutdown_requested.load(Ordering::Acquire));
|
||
assert!(state.draining.load(Ordering::Acquire));
|
||
}
|
||
|
||
#[test]
|
||
fn stale_protocol_endpoint_does_not_override_instance_lock_arbitration() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let endpoint_path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME);
|
||
let mut stale = test_endpoint(
|
||
"stale-private-token-stale-private-token",
|
||
"stale-boot-id",
|
||
33333,
|
||
);
|
||
stale.protocol_version = EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION + 1;
|
||
write_external_agent_runner_endpoint_atomic(&endpoint_path, &stale)
|
||
.expect("write stale endpoint");
|
||
|
||
assert!(read_current_external_agent_runner_endpoint(
|
||
&endpoint_path,
|
||
stale
|
||
.executable_fingerprint
|
||
.as_deref()
|
||
.expect("test fingerprint"),
|
||
)
|
||
.is_none());
|
||
let boot_id = "current-lock-owner";
|
||
let lock = acquire_external_agent_runner_instance_lock(
|
||
&external_agent_runner_lock_path(&config_dir),
|
||
boot_id,
|
||
)
|
||
.expect("stale endpoint must not block the authoritative instance lock");
|
||
drop(lock);
|
||
}
|
||
|
||
#[test]
|
||
fn active_runner_lock_is_not_repaired_or_truncated() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let lock_path = external_agent_runner_lock_path(&config_dir);
|
||
let first = acquire_external_agent_runner_instance_lock(&lock_path, "first-active-boot")
|
||
.expect("acquire first runner lock");
|
||
|
||
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "second-boot") {
|
||
Ok(_) => panic!("active runner lock must reject a second owner"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("其他进程运行"));
|
||
drop(first);
|
||
let diagnostic: Value = serde_json::from_slice(
|
||
&fs::read(&lock_path).expect("read runner lock after rejected acquisition"),
|
||
)
|
||
.expect("parse runner lock after rejected acquisition");
|
||
assert_eq!(diagnostic["bootId"], "first-active-boot");
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_runner_lock_rejects_unrecognized_fixed_name() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let unexpected_path = config_dir.join("unexpected-runner.lock");
|
||
|
||
let error = try_open_external_agent_runner_lock(&unexpected_path, "unexpected Runner lock")
|
||
.expect_err("only the instance and GUI owner lock names are allowed");
|
||
|
||
assert!(error.contains("固定锁路径"));
|
||
assert!(!unexpected_path.exists());
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_stale_runner_lock_is_reowned_for_token_user() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let lock_path = external_agent_runner_lock_path(&config_dir);
|
||
fs::write(&lock_path, b"stale-lock-from-token-default-owner")
|
||
.expect("create stale runner lock");
|
||
if !crate::tests::configuration::set_windows_test_path_owner_to_distinct_token_owner(&lock_path)
|
||
{
|
||
eprintln!("skip: 当前 Windows token 没有区别于 TokenUser 且可设置的默认 owner SID");
|
||
return;
|
||
}
|
||
assert!(
|
||
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false).is_err(),
|
||
"fixture lock must start with a foreign owner"
|
||
);
|
||
|
||
let lock = acquire_external_agent_runner_instance_lock(&lock_path, "reowned-boot")
|
||
.expect("repair and acquire stale runner lock");
|
||
|
||
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)
|
||
.expect("runner lock owner must match TokenUser SID");
|
||
drop(lock);
|
||
let diagnostic: Value = serde_json::from_slice(
|
||
&fs::read(&lock_path).expect("read repaired runner lock diagnostic"),
|
||
)
|
||
.expect("parse repaired runner lock diagnostic");
|
||
assert_eq!(diagnostic["bootId"], "reowned-boot");
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_runner_lock_rejects_hard_link_without_touching_target() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let target = config_dir.join("lock-target.txt");
|
||
let lock_path = external_agent_runner_lock_path(&config_dir);
|
||
fs::write(&target, b"do-not-truncate").expect("write lock target");
|
||
fs::hard_link(&target, &lock_path).expect("create runner lock hard link");
|
||
|
||
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") {
|
||
Ok(_) => panic!("runner lock hard link must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("硬链接"));
|
||
assert_eq!(
|
||
fs::read(&target).expect("read untouched lock target"),
|
||
b"do-not-truncate"
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_runner_lock_rejects_symlink_without_touching_target_when_supported() {
|
||
use std::os::windows::fs::symlink_file;
|
||
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let target = config_dir.join("lock-symlink-target.txt");
|
||
let lock_path = external_agent_runner_lock_path(&config_dir);
|
||
fs::write(&target, b"do-not-truncate").expect("write lock symlink target");
|
||
if symlink_file(&target, &lock_path).is_err() {
|
||
eprintln!("skip: 当前 Windows 环境不允许创建文件符号链接");
|
||
return;
|
||
}
|
||
|
||
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") {
|
||
Ok(_) => panic!("runner lock symlink must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("reparse point") || error.contains("普通文件"));
|
||
assert_eq!(
|
||
fs::read(&target).expect("read untouched lock symlink target"),
|
||
b"do-not-truncate"
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_runner_lock_busy_error_classification_is_exact() {
|
||
assert!(windows_external_agent_runner_lock_is_busy_error(
|
||
&io::Error::from_raw_os_error(32)
|
||
));
|
||
assert!(windows_external_agent_runner_lock_is_busy_error(
|
||
&io::Error::from_raw_os_error(33)
|
||
));
|
||
assert!(!windows_external_agent_runner_lock_is_busy_error(
|
||
&io::Error::from_raw_os_error(5)
|
||
));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn runner_lock_rejects_symlink_without_touching_target() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let directory = unique_test_directory();
|
||
let target = directory.0.join("lock-target.txt");
|
||
let lock_path = directory.0.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME);
|
||
fs::write(&target, b"do-not-truncate").expect("write lock target");
|
||
symlink(&target, &lock_path).expect("create runner lock symlink");
|
||
|
||
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "symlink-boot") {
|
||
Ok(_) => panic!("runner lock symlink must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("锁"));
|
||
assert_eq!(
|
||
fs::read(&target).expect("read untouched lock target"),
|
||
b"do-not-truncate"
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn runner_lock_rejects_hard_link_without_touching_target() {
|
||
let directory = unique_test_directory();
|
||
let target = directory.0.join("hard-link-target.txt");
|
||
let lock_path = directory.0.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME);
|
||
fs::write(&target, b"do-not-truncate").expect("write lock target");
|
||
fs::hard_link(&target, &lock_path).expect("create runner lock hard link");
|
||
|
||
let error = match acquire_external_agent_runner_instance_lock(&lock_path, "hard-link-boot") {
|
||
Ok(_) => panic!("runner lock hard link must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("硬链接"));
|
||
assert_eq!(
|
||
fs::read(&target).expect("read untouched lock target"),
|
||
b"do-not-truncate"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_is_unique_across_appdata_and_records_recovery() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-owner-test", "Runner owner 测试")
|
||
.expect("initialize owner project");
|
||
let config_a = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("app-a"))
|
||
.expect("prepare appdata a");
|
||
let config_b = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("app-b"))
|
||
.expect("prepare appdata b");
|
||
let state_a = ExternalAgentRunnerServerState::new(
|
||
config_a.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-private-token-a-owner-private-token-a",
|
||
"owner-boot-a",
|
||
41001,
|
||
),
|
||
);
|
||
let state_b = ExternalAgentRunnerServerState::new(
|
||
config_b.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-private-token-b-owner-private-token-b",
|
||
"owner-boot-b",
|
||
41002,
|
||
),
|
||
);
|
||
|
||
let first_claim = state_a
|
||
.claim_project_execution_owner(&root)
|
||
.expect("first appdata owns project");
|
||
assert!(first_claim.recovered_from_boot_id.is_none());
|
||
let conflict = state_b
|
||
.claim_project_execution_owner(&root)
|
||
.expect_err("second appdata must not own the same project");
|
||
assert!(conflict.contains("execution-owner"));
|
||
|
||
drop(state_a);
|
||
let mut recovered = false;
|
||
for attempt in 0..100 {
|
||
match state_b.claim_project_execution_owner(&root) {
|
||
Ok(claim) => {
|
||
assert_eq!(
|
||
claim.recovered_from_boot_id.as_deref(),
|
||
Some("owner-boot-a")
|
||
);
|
||
recovered = true;
|
||
break;
|
||
}
|
||
Err(error) if error.contains("另一个 Agent Runner") && attempt < 99 => {
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
Err(error) => panic!("released OS lock recovery failed: {error}"),
|
||
}
|
||
}
|
||
assert!(recovered, "released OS lock was not reacquired");
|
||
let record = serde_json::from_slice::<ExternalAgentRunnerProjectExecutionOwnerRecord>(
|
||
&fs::read(root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH))
|
||
.expect("read project owner record"),
|
||
)
|
||
.expect("parse project owner record");
|
||
assert_eq!(
|
||
record.protocol_version,
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION
|
||
);
|
||
assert_eq!(record.boot_id, "owner-boot-b");
|
||
assert_eq!(
|
||
record.recovered_from_boot_id.as_deref(),
|
||
Some("owner-boot-a")
|
||
);
|
||
#[cfg(windows)]
|
||
crate::secure_windows_game_creator_path_for_current_user(
|
||
&root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH),
|
||
false,
|
||
false,
|
||
)
|
||
.expect("project owner diagnostic must match TokenUser SID");
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_cross_boot_claim_recovers_durable_runtime_once() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-auto-recovery",
|
||
"Runner owner 自动恢复测试",
|
||
)
|
||
.expect("initialize owner auto recovery project");
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner auto recovery appdata");
|
||
let previous = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-auto-recovery-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire previous owner");
|
||
drop(previous);
|
||
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-auto-recovery-token-owner-auto-recovery-token",
|
||
"owner-auto-recovery-boot-b",
|
||
41003,
|
||
),
|
||
);
|
||
let task = AgentRuntimeTaskRecord {
|
||
goal_id: None,
|
||
goal_revision: 0,
|
||
goal_status: None,
|
||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||
agent_id: "design-director".to_string(),
|
||
task_id: "design-director".to_string(),
|
||
session_id: "agent-session-design-director".to_string(),
|
||
run_id: "owner-auto-recovery-run".to_string(),
|
||
source: "agent-background-task".to_string(),
|
||
run_profile: default_agent_runtime_run_profile(),
|
||
run_profile_binding_fingerprint: String::new(),
|
||
parent_agent_id: None,
|
||
parent_run_id: None,
|
||
delegation_id: None,
|
||
task: "跨 boot 恢复 durable task".to_string(),
|
||
status: "running".to_string(),
|
||
phase: "planning".to_string(),
|
||
current_action: "上一 boot 正在规划".to_string(),
|
||
terminal_detail: None,
|
||
error: None,
|
||
updated_at: unix_timestamp().saturating_sub(60),
|
||
};
|
||
let task_path = root.join(".agent/runtime/tasks/design-director.jsonl");
|
||
fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir");
|
||
fs::write(
|
||
&task_path,
|
||
format!(
|
||
"{}\n",
|
||
serde_json::to_string(&task).expect("serialize durable task")
|
||
),
|
||
)
|
||
.expect("write durable task");
|
||
let mut runtime = crate::agent_runtime_state_from_task_record(&task);
|
||
crate::write_game_creator_agent_runtime_state(&root, &runtime)
|
||
.expect("write stale runtime state");
|
||
let project_id = crate::game_creator_agent_runtime_context_project_id(&root)
|
||
.expect("read runtime project id");
|
||
let process_id = "proc-0123456789abcdef0123456789abcdef";
|
||
let now = unix_timestamp();
|
||
let process_record = ProcessSessionRecord {
|
||
schema_version: "3".to_string(),
|
||
project_id,
|
||
agent_id: task.agent_id.clone(),
|
||
task_id: task.task_id.clone(),
|
||
conversation_session_id: task.session_id.clone(),
|
||
run_id: task.run_id.clone(),
|
||
start_action_id: "action-0123456789abcdef01234567".to_string(),
|
||
start_action_fingerprint: "a".repeat(64),
|
||
process_id: process_id.to_string(),
|
||
owner_boot_id: "owner-auto-recovery-boot-a".to_string(),
|
||
command_id: "command-owner-auto-recovery".to_string(),
|
||
program: "node".to_string(),
|
||
cwd: ".".to_string(),
|
||
sandbox_backend: "test-unknown".to_string(),
|
||
sandbox_mode: "unknown".to_string(),
|
||
network_access: "unknown".to_string(),
|
||
sandbox_profile_version: "test-v1".to_string(),
|
||
sandbox_establishment: "established".to_string(),
|
||
target_exec: "established".to_string(),
|
||
launch_failure_kind: None,
|
||
sandbox_ready_at: Some(now),
|
||
exec_established_at: Some(now),
|
||
status: "running".to_string(),
|
||
exit_code: None,
|
||
signal: None,
|
||
stdin_open: true,
|
||
output_bytes: 0,
|
||
output_sha256: format!("{:x}", Sha256::digest([])),
|
||
output_ref: None,
|
||
source_fingerprint_before: "b".repeat(64),
|
||
source_fingerprint_after: None,
|
||
source_changed: None,
|
||
needs_reconciliation: false,
|
||
started_at: now,
|
||
terminal_at: None,
|
||
updated_at: now,
|
||
};
|
||
let process_path = root.join(format!(".agent/runtime/process-sessions/{process_id}.json"));
|
||
fs::create_dir_all(process_path.parent().expect("process session parent"))
|
||
.expect("process session directory");
|
||
fs::write(
|
||
&process_path,
|
||
serde_json::to_vec_pretty(&process_record).expect("serialize process session"),
|
||
)
|
||
.expect("write previous boot process session");
|
||
|
||
let first = state
|
||
.claim_project_execution_owner(&root)
|
||
.expect("cross boot claim automatically scans recovery");
|
||
assert_eq!(
|
||
first.recovered_from_boot_id.as_deref(),
|
||
Some("owner-auto-recovery-boot-a")
|
||
);
|
||
runtime = crate::read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read reconciled runtime")
|
||
.state;
|
||
assert_eq!(runtime.run_id, "owner-auto-recovery-run");
|
||
assert_eq!(runtime.phase, "needs-reconciliation");
|
||
assert!(runtime
|
||
.error
|
||
.as_deref()
|
||
.is_some_and(|error| error.contains("禁止按 PID 重连或重放启动动作")));
|
||
let process = serde_json::from_slice::<ProcessSessionRecord>(
|
||
&fs::read(&process_path).expect("read reconciled process session"),
|
||
)
|
||
.expect("parse reconciled process session");
|
||
assert_eq!(process.status, "needs-reconciliation");
|
||
assert!(process.needs_reconciliation);
|
||
let records = fs::read_to_string(root.join(".agent/agent.db")).expect("read recovery audit");
|
||
assert_eq!(
|
||
records
|
||
.matches("agent.runtime.process_session.reconciled_after_runner_restart")
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(!records.contains("agent.runtime.tool_action.executing"));
|
||
assert!(!records.contains("agent.runtime.agent.delegate"));
|
||
let task_records_before = fs::read_to_string(&task_path).expect("read reconciled task journal");
|
||
let task_record_count_before = task_records_before
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.count();
|
||
let conversation_directory = root.join(".agent/conversations");
|
||
let conversation_snapshot_before = if conversation_directory.exists() {
|
||
let mut files = fs::read_dir(&conversation_directory)
|
||
.expect("read conversation directory")
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.path())
|
||
.filter(|path| path.is_file())
|
||
.map(|path| {
|
||
(
|
||
path.clone(),
|
||
fs::read(path).expect("read conversation file"),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||
files
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
let second = state
|
||
.claim_project_execution_owner(&root)
|
||
.expect("same boot claim is idempotent");
|
||
assert!(second.recovered_from_boot_id.is_none());
|
||
let records_after =
|
||
fs::read_to_string(root.join(".agent/agent.db")).expect("read idempotent recovery audit");
|
||
assert_eq!(records_after, records);
|
||
let task_records_after =
|
||
fs::read_to_string(&task_path).expect("reread reconciled task journal");
|
||
assert_eq!(
|
||
task_records_after
|
||
.lines()
|
||
.filter(|line| !line.trim().is_empty())
|
||
.count(),
|
||
task_record_count_before,
|
||
"same-boot reentry must not duplicate durable task/child projection"
|
||
);
|
||
let conversation_snapshot_after = if conversation_directory.exists() {
|
||
let mut files = fs::read_dir(&conversation_directory)
|
||
.expect("reread conversation directory")
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.path())
|
||
.filter(|path| path.is_file())
|
||
.map(|path| {
|
||
(
|
||
path.clone(),
|
||
fs::read(path).expect("reread conversation file"),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||
files
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
assert_eq!(conversation_snapshot_after, conversation_snapshot_before);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_recovery_runs_after_owner_map_lock_release() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-lock-order",
|
||
"Runner owner 恢复锁顺序测试",
|
||
)
|
||
.expect("initialize owner lock order project");
|
||
let previous = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-lock-order-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire previous owner");
|
||
drop(previous);
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner lock order appdata");
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-lock-order-token-owner-lock-order-token",
|
||
"owner-lock-order-boot-b",
|
||
41004,
|
||
),
|
||
);
|
||
|
||
let claim = state
|
||
.claim_project_execution_owner_with_recovery(&root, |claimed_root| {
|
||
let owners = state
|
||
.project_execution_owners
|
||
.try_lock()
|
||
.expect("recovery callback must run after owner-map lock release");
|
||
assert!(owners.contains_key(claimed_root));
|
||
Ok(())
|
||
})
|
||
.expect("claim with lock-order probe");
|
||
assert_eq!(
|
||
claim.recovered_from_boot_id.as_deref(),
|
||
Some("owner-lock-order-boot-a")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_recovery_failure_retries_in_same_boot() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-recovery-retry",
|
||
"Runner owner 恢复重试测试",
|
||
)
|
||
.expect("initialize owner recovery retry project");
|
||
let previous = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-recovery-retry-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire previous owner");
|
||
drop(previous);
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner recovery retry appdata");
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-recovery-retry-token-owner-retry-token",
|
||
"owner-recovery-retry-boot-b",
|
||
41005,
|
||
),
|
||
);
|
||
let recoveries = AtomicUsize::new(0);
|
||
|
||
let first = state
|
||
.claim_project_execution_owner_with_recovery(&root, |_| {
|
||
recoveries.fetch_add(1, Ordering::SeqCst);
|
||
Err("injected recovery failure".to_string())
|
||
})
|
||
.expect_err("first recovery attempt must surface its failure");
|
||
assert!(first.contains("injected recovery failure"));
|
||
assert_eq!(recoveries.load(Ordering::SeqCst), 1);
|
||
|
||
let second = state
|
||
.claim_project_execution_owner_with_recovery(&root, |_| {
|
||
recoveries.fetch_add(1, Ordering::SeqCst);
|
||
Ok(())
|
||
})
|
||
.expect("same boot must retry failed recovery");
|
||
assert_eq!(
|
||
second.recovered_from_boot_id.as_deref(),
|
||
Some("owner-recovery-retry-boot-a")
|
||
);
|
||
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
|
||
|
||
let third = state
|
||
.claim_project_execution_owner_with_recovery(&root, |_| {
|
||
recoveries.fetch_add(1, Ordering::SeqCst);
|
||
Ok(())
|
||
})
|
||
.expect("completed recovery remains idempotent");
|
||
assert!(third.recovered_from_boot_id.is_none());
|
||
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_concurrent_claims_single_flight_recovery() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-recovery-single-flight",
|
||
"Runner owner 单飞恢复测试",
|
||
)
|
||
.expect("initialize owner recovery single-flight project");
|
||
let previous = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-recovery-single-flight-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire previous owner");
|
||
drop(previous);
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner recovery single-flight appdata");
|
||
let state = Arc::new(ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-recovery-single-flight-token-single-flight",
|
||
"owner-recovery-single-flight-boot-b",
|
||
41006,
|
||
),
|
||
));
|
||
let recoveries = Arc::new(AtomicUsize::new(0));
|
||
let recovery_entered = Arc::new(Barrier::new(2));
|
||
let (release_recovery_tx, release_recovery_rx) = mpsc::channel::<()>();
|
||
|
||
let first_state = Arc::clone(&state);
|
||
let first_root = root.clone();
|
||
let first_recoveries = Arc::clone(&recoveries);
|
||
let first_recovery_entered = Arc::clone(&recovery_entered);
|
||
let first = std::thread::spawn(move || {
|
||
first_state.claim_project_execution_owner_with_recovery(&first_root, |_| {
|
||
first_recoveries.fetch_add(1, Ordering::SeqCst);
|
||
first_recovery_entered.wait();
|
||
release_recovery_rx
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.expect("test must release the active recovery");
|
||
Ok(())
|
||
})
|
||
});
|
||
recovery_entered.wait();
|
||
|
||
let second_state = Arc::clone(&state);
|
||
let second_root = root.clone();
|
||
let second_recoveries = Arc::clone(&recoveries);
|
||
let (second_started_tx, second_started_rx) = mpsc::channel();
|
||
let second = std::thread::spawn(move || {
|
||
second_started_tx.send(()).expect("signal second claim");
|
||
second_state.claim_project_execution_owner_with_recovery(&second_root, |_| {
|
||
second_recoveries.fetch_add(1, Ordering::SeqCst);
|
||
Ok(())
|
||
})
|
||
});
|
||
second_started_rx
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.expect("second claim must start");
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
assert_eq!(
|
||
recoveries.load(Ordering::SeqCst),
|
||
1,
|
||
"concurrent claim must wait instead of duplicating recovery"
|
||
);
|
||
|
||
release_recovery_tx
|
||
.send(())
|
||
.expect("release active recovery");
|
||
let first_claim = first
|
||
.join()
|
||
.expect("first claim thread")
|
||
.expect("first claim");
|
||
let second_claim = second
|
||
.join()
|
||
.expect("second claim thread")
|
||
.expect("second claim");
|
||
assert_eq!(
|
||
first_claim.recovered_from_boot_id.as_deref(),
|
||
Some("owner-recovery-single-flight-boot-a")
|
||
);
|
||
assert!(second_claim.recovered_from_boot_id.is_none());
|
||
assert_eq!(recoveries.load(Ordering::SeqCst), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_recovery_panic_resets_running_and_wakes_waiter() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-recovery-panic",
|
||
"Runner owner 恢复 panic 测试",
|
||
)
|
||
.expect("initialize owner recovery panic project");
|
||
let previous = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-recovery-panic-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire previous owner");
|
||
drop(previous);
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner recovery panic appdata");
|
||
let state = Arc::new(ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(
|
||
"owner-recovery-panic-token-owner-panic-token",
|
||
"owner-recovery-panic-boot-b",
|
||
41007,
|
||
),
|
||
));
|
||
let recoveries = Arc::new(AtomicUsize::new(0));
|
||
let (first_entered_tx, first_entered_rx) = mpsc::channel();
|
||
let (release_first_tx, release_first_rx) = mpsc::channel();
|
||
|
||
let first_state = Arc::clone(&state);
|
||
let first_root = root.clone();
|
||
let first_recoveries = Arc::clone(&recoveries);
|
||
let first = std::thread::spawn(move || {
|
||
first_state.claim_project_execution_owner_with_recovery(&first_root, |_| {
|
||
first_recoveries.fetch_add(1, Ordering::SeqCst);
|
||
first_entered_tx.send(()).expect("signal panic recovery");
|
||
release_first_rx
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.expect("release panic recovery");
|
||
panic!("injected recovery callback panic");
|
||
})
|
||
});
|
||
first_entered_rx
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.expect("first recovery must enter");
|
||
|
||
let second_state = Arc::clone(&state);
|
||
let second_root = root.clone();
|
||
let second_recoveries = Arc::clone(&recoveries);
|
||
let (second_started_tx, second_started_rx) = mpsc::channel();
|
||
let second = std::thread::spawn(move || {
|
||
second_started_tx.send(()).expect("signal waiting claim");
|
||
second_state.claim_project_execution_owner_with_recovery(&second_root, |_| {
|
||
second_recoveries.fetch_add(1, Ordering::SeqCst);
|
||
Ok(())
|
||
})
|
||
});
|
||
second_started_rx
|
||
.recv_timeout(Duration::from_secs(5))
|
||
.expect("waiting claim must start");
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
assert_eq!(
|
||
recoveries.load(Ordering::SeqCst),
|
||
1,
|
||
"waiter must not run while the first recovery is Running"
|
||
);
|
||
|
||
release_first_tx
|
||
.send(())
|
||
.expect("release the panicking recovery");
|
||
let first_error = first
|
||
.join()
|
||
.expect("panic must be contained inside owner recovery")
|
||
.expect_err("panicking recovery must fail its own claim");
|
||
assert!(first_error.contains("恢复回调异常终止"));
|
||
let second_claim = second
|
||
.join()
|
||
.expect("waiting claim thread must be woken")
|
||
.expect("waiting claim must retry recovery");
|
||
assert_eq!(
|
||
second_claim.recovered_from_boot_id.as_deref(),
|
||
Some("owner-recovery-panic-boot-a")
|
||
);
|
||
assert_eq!(recoveries.load(Ordering::SeqCst), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn project_execution_owner_recovery_runs_without_request_cache_and_failure_is_retryable() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(
|
||
&root,
|
||
"project-owner-request-cache",
|
||
"Runner owner 请求缓存锁测试",
|
||
)
|
||
.expect("initialize owner request cache project");
|
||
let appdata = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare owner request cache appdata");
|
||
let token = "owner-request-cache-token-owner-request-cache-token";
|
||
let state = ExternalAgentRunnerServerState::new(
|
||
appdata.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME),
|
||
test_endpoint(token, "owner-request-cache-boot", 41008),
|
||
);
|
||
let request = ExternalAgentRunnerRequest {
|
||
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
request_id: "owner-request-cache-request".to_string(),
|
||
token: token.to_string(),
|
||
method: "runtime.resume".to_string(),
|
||
params: ExternalAgentRunnerRequestParams {
|
||
root: Some(root.to_string_lossy().into_owned()),
|
||
..ExternalAgentRunnerRequestParams::default()
|
||
},
|
||
};
|
||
let attempts = AtomicUsize::new(0);
|
||
|
||
let first =
|
||
dispatch_external_agent_runner_runtime_request_with_owner_claim(&request, &state, |_| {
|
||
attempts.fetch_add(1, Ordering::SeqCst);
|
||
let cache = state
|
||
.write_request_cache
|
||
.try_lock()
|
||
.expect("owner recovery must not run under the global request-cache mutex");
|
||
assert!(cache.find(&request.request_id).is_none());
|
||
Err("injected owner recovery failure".to_string())
|
||
});
|
||
assert!(!first.ok);
|
||
assert_eq!(
|
||
first.error.as_ref().map(|error| error.code.as_str()),
|
||
Some("project-execution-owned")
|
||
);
|
||
assert!(lock_unpoisoned(&state.write_request_cache)
|
||
.find(&request.request_id)
|
||
.is_none());
|
||
|
||
let second = dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||
&request,
|
||
&state,
|
||
|requested_root| {
|
||
attempts.fetch_add(1, Ordering::SeqCst);
|
||
Ok(ExternalAgentRunnerProjectExecutionOwnerClaim {
|
||
root: canonicalize_external_agent_runner_project_root(requested_root)?,
|
||
recovered_from_boot_id: Some("previous-boot".to_string()),
|
||
})
|
||
},
|
||
);
|
||
assert!(second.ok, "same requestId retry failed: {:?}", second.error);
|
||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||
|
||
let replay = dispatch_external_agent_runner_runtime_request_with_owner_claim(
|
||
&request,
|
||
&state,
|
||
|_| -> Result<ExternalAgentRunnerProjectExecutionOwnerClaim, String> {
|
||
panic!("cached replay must not claim the owner again")
|
||
},
|
||
);
|
||
assert_eq!(replay, second);
|
||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn corrupt_legacy_owner_diagnostic_does_not_block_lock_recovery() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-owner-recovery", "Runner owner 恢复测试")
|
||
.expect("initialize owner recovery project");
|
||
let owner_path = root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_PATH);
|
||
|
||
let first = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-recovery-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire first owner");
|
||
drop(first);
|
||
fs::remove_file(root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH))
|
||
.expect("remove new diagnostic to exercise legacy recovery");
|
||
fs::write(&owner_path, br#"{"protocolVersion":1,"bootId":"partial"#)
|
||
.expect("write partial legacy owner diagnostic");
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
fs::set_permissions(&owner_path, fs::Permissions::from_mode(0o600))
|
||
.expect("keep legacy owner lock private");
|
||
}
|
||
|
||
let recovered = acquire_project_owner_after_release(&root, "owner-recovery-boot-b");
|
||
let conflict = match acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-recovery-boot-c",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
) {
|
||
Ok(_) => panic!("diagnostic recovery must not permit split-brain"),
|
||
Err(error) => error,
|
||
};
|
||
assert!(conflict.contains("execution-owner"));
|
||
drop(recovered);
|
||
}
|
||
|
||
#[test]
|
||
fn partial_owner_diagnostic_is_atomically_recovered_after_os_lock() {
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-owner-diagnostic", "Runner 诊断恢复测试")
|
||
.expect("initialize owner diagnostic project");
|
||
let diagnostic_path = root.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_PATH);
|
||
|
||
let first = acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-diagnostic-boot-a",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
)
|
||
.expect("acquire first diagnostic owner");
|
||
drop(first);
|
||
fs::write(
|
||
&diagnostic_path,
|
||
br#"{"protocolVersion":1,"bootId":"partial"#,
|
||
)
|
||
.expect("write partial owner diagnostic");
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
fs::set_permissions(&diagnostic_path, fs::Permissions::from_mode(0o600))
|
||
.expect("keep partial diagnostic private");
|
||
}
|
||
|
||
let recovered = acquire_project_owner_after_release(&root, "owner-diagnostic-boot-b");
|
||
let record = serde_json::from_slice::<ExternalAgentRunnerProjectExecutionOwnerRecord>(
|
||
&fs::read(&diagnostic_path).expect("read recovered diagnostic"),
|
||
)
|
||
.expect("parse recovered diagnostic");
|
||
assert_eq!(record.boot_id, "owner-diagnostic-boot-b");
|
||
let conflict = match acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-diagnostic-boot-c",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
) {
|
||
Ok(_) => panic!("diagnostic repair must not permit split-brain"),
|
||
Err(error) => error,
|
||
};
|
||
assert!(conflict.contains("execution-owner"));
|
||
drop(recovered);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn project_owner_relative_open_does_not_follow_parent_replacement_race() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-owner-race", "Runner owner 竞态测试")
|
||
.expect("initialize owner race project");
|
||
let root_directory = open_unix_project_owner_root(&root).expect("open project root handle");
|
||
let agent_directory =
|
||
open_unix_project_owner_directory_at(&root_directory, ".agent", "项目 .agent 目录", false)
|
||
.expect("open project agent handle");
|
||
|
||
let original_agent = root.join(".agent-original");
|
||
fs::rename(root.join(".agent"), &original_agent).expect("move original agent directory");
|
||
let outside_agent = directory.0.join("outside-agent");
|
||
let outside_runtime = outside_agent.join("runtime");
|
||
fs::create_dir_all(&outside_runtime).expect("create outside agent runtime");
|
||
let outside_lock = outside_runtime.join(EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME);
|
||
fs::write(&outside_lock, b"outside-sentinel").expect("write outside lock sentinel");
|
||
symlink(&outside_agent, root.join(".agent")).expect("replace agent path with symlink");
|
||
|
||
let original_runtime = open_unix_project_owner_directory_at(
|
||
&agent_directory,
|
||
"runtime",
|
||
"项目 Runtime owner 目录",
|
||
false,
|
||
)
|
||
.expect("relative open must stay on the original agent directory");
|
||
let lock_path = original_agent.join("runtime/execution-owner.lock");
|
||
let lock = try_open_unix_project_owner_lock_at(&original_runtime, &lock_path)
|
||
.expect("open owner lock relative to original runtime")
|
||
.expect("lock original runtime");
|
||
|
||
assert_eq!(
|
||
fs::read(&outside_lock).expect("read untouched outside lock"),
|
||
b"outside-sentinel"
|
||
);
|
||
assert!(lock_path.is_file());
|
||
assert!(verify_unix_project_owner_entry(
|
||
&root_directory,
|
||
".agent",
|
||
&agent_directory,
|
||
true,
|
||
"项目 .agent 目录",
|
||
)
|
||
.is_err());
|
||
drop(lock);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn project_execution_owner_rejects_symlinked_runtime_parent_without_touching_target() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let directory = unique_test_directory();
|
||
let root = directory.0.join("project");
|
||
crate::init_local_game_project_at(&root, "project-owner-parent", "Runner owner 父目录测试")
|
||
.expect("initialize owner parent project");
|
||
let runtime_dir = root.join(".agent/runtime");
|
||
fs::remove_dir_all(&runtime_dir).expect("remove real runtime directory");
|
||
let outside_runtime = directory.0.join("outside-runtime");
|
||
fs::create_dir(&outside_runtime).expect("create outside runtime directory");
|
||
let outside_lock = outside_runtime.join("execution-owner.lock");
|
||
fs::write(&outside_lock, b"outside-sentinel").expect("write outside sentinel");
|
||
symlink(&outside_runtime, &runtime_dir).expect("link runtime to outside directory");
|
||
|
||
let error = match acquire_external_agent_runner_project_execution_owner(
|
||
&root,
|
||
"owner-parent-boot",
|
||
EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
|
||
) {
|
||
Ok(_) => panic!("symlinked Runtime owner parent must be rejected"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("Runtime owner") || error.contains("链接"));
|
||
assert_eq!(
|
||
fs::read(&outside_lock).expect("read untouched outside sentinel"),
|
||
b"outside-sentinel"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn runner_status_read_does_not_create_or_start_runner() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare status appdata");
|
||
|
||
let status = read_external_agent_runner_status_at(Some(&config_dir));
|
||
|
||
assert!(status.enabled);
|
||
assert!(!status.running);
|
||
assert!(!external_agent_runner_endpoint_path(&config_dir).exists());
|
||
assert!(!external_agent_runner_lock_path(&config_dir).exists());
|
||
}
|
||
|
||
#[test]
|
||
fn runner_status_read_does_not_create_missing_appdata() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = directory.0.join("missing-appdata");
|
||
|
||
let status = read_external_agent_runner_status_at(Some(&config_dir));
|
||
|
||
assert!(status.enabled);
|
||
assert!(!status.running);
|
||
assert!(!config_dir.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn runner_listener_retries_fallback_ports_only_after_address_in_use() {
|
||
let mut attempts = Vec::new();
|
||
let bound_port = bind_external_agent_runner_listener_with(
|
||
|| vec![61_000, 61_001],
|
||
|port| {
|
||
attempts.push(port);
|
||
if matches!(port, 0 | 61_000) {
|
||
Err(io::Error::new(io::ErrorKind::AddrInUse, "occupied"))
|
||
} else {
|
||
Ok(port)
|
||
}
|
||
},
|
||
)
|
||
.expect("fallback listener");
|
||
|
||
assert_eq!(bound_port, 61_001);
|
||
assert_eq!(attempts, vec![0, 61_000, 61_001]);
|
||
}
|
||
|
||
#[test]
|
||
fn runner_listener_preserves_non_address_in_use_failure() {
|
||
let mut attempts = Vec::new();
|
||
let error = bind_external_agent_runner_listener_with(
|
||
|| vec![61_000],
|
||
|port| {
|
||
attempts.push(port);
|
||
Err::<(), _>(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))
|
||
},
|
||
)
|
||
.expect_err("permission failure must not use fallback ports");
|
||
|
||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||
assert_eq!(attempts, vec![0]);
|
||
}
|
||
|
||
#[test]
|
||
fn runner_listener_does_not_load_fallback_ports_when_port_zero_succeeds() {
|
||
let mut fallback_loaded = false;
|
||
let bound_port = bind_external_agent_runner_listener_with(
|
||
|| {
|
||
fallback_loaded = true;
|
||
vec![61_000]
|
||
},
|
||
Ok,
|
||
)
|
||
.expect("port zero listener");
|
||
|
||
assert_eq!(bound_port, 0);
|
||
assert!(!fallback_loaded);
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
#[test]
|
||
fn linux_runner_fallback_ports_stay_outside_ephemeral_range() {
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_ephemeral_port_range("32768\t60999\n"),
|
||
Some((32_768, 60_999))
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_ephemeral_port_range("60999 32768"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_ephemeral_port_range("32768 60999 extra"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_single_port("32768\n"),
|
||
Some(32_768)
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_single_port("32768 extra"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_reserved_ports("61001-61003, 65535\n"),
|
||
Some(vec![(61_001, 61_003), (65_535, 65_535)])
|
||
);
|
||
assert_eq!(
|
||
parse_external_agent_runner_linux_reserved_ports("61003-61001"),
|
||
None
|
||
);
|
||
|
||
let ports = external_agent_runner_linux_fallback_ports(
|
||
"runner-listener-fallback-test-boot",
|
||
(32_768, 60_999),
|
||
32_768,
|
||
&[(61_001, 61_003), (65_535, 65_535)],
|
||
);
|
||
assert_eq!(ports.len(), 4_532);
|
||
assert!(ports.iter().all(|port| {
|
||
*port >= EXTERNAL_AGENT_RUNNER_FALLBACK_PORT_START
|
||
&& !(32_768..=60_999).contains(port)
|
||
&& !(61_001..=61_003).contains(port)
|
||
&& *port != 65_535
|
||
}));
|
||
assert_eq!(
|
||
ports.iter().copied().collect::<BTreeSet<_>>().len(),
|
||
ports.len()
|
||
);
|
||
let hardened_ports = external_agent_runner_linux_fallback_ports(
|
||
"runner-listener-hardened-boot",
|
||
(32_768, 60_999),
|
||
62_000,
|
||
&[],
|
||
);
|
||
assert!(hardened_ports.iter().all(|port| *port >= 62_000));
|
||
assert!(external_agent_runner_linux_fallback_ports(
|
||
"runner-listener-exhausted-boot",
|
||
(32_768, 60_999),
|
||
65_535,
|
||
&[(65_535, 65_535)],
|
||
)
|
||
.is_empty());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn read_only_runner_configuration_does_not_chmod_appdata() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let directory = unique_test_directory();
|
||
let config_dir = directory.0.join("broad-appdata");
|
||
fs::create_dir(&config_dir).expect("create broad appdata");
|
||
fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o755))
|
||
.expect("set broad appdata mode");
|
||
|
||
let error = configure_external_agent_runner_read_only(&config_dir)
|
||
.expect_err("read-only configuration must reject broad AppData without tightening it");
|
||
|
||
assert!(error.contains("0700"));
|
||
assert_eq!(
|
||
fs::metadata(&config_dir)
|
||
.expect("read broad appdata metadata")
|
||
.permissions()
|
||
.mode()
|
||
& 0o777,
|
||
0o755
|
||
);
|
||
assert!(!external_agent_runner_endpoint_path(&config_dir).exists());
|
||
assert!(!external_agent_runner_lock_path(&config_dir).exists());
|
||
}
|
||
|
||
#[test]
|
||
fn endpoint_write_is_atomic_and_private() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
let path = config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME);
|
||
let first = test_endpoint(
|
||
"first-private-token-first-private-token",
|
||
"boot-first",
|
||
10101,
|
||
);
|
||
let second = test_endpoint(
|
||
"second-private-token-second-private-token",
|
||
"boot-second",
|
||
20202,
|
||
);
|
||
write_external_agent_runner_endpoint_atomic(&path, &first).expect("write first endpoint");
|
||
write_external_agent_runner_endpoint_atomic(&path, &second)
|
||
.expect("replace endpoint atomically");
|
||
|
||
let persisted = read_external_agent_runner_endpoint(&path).expect("read endpoint");
|
||
assert_eq!(persisted.boot_id, "boot-second");
|
||
assert_eq!(persisted.port, 20202);
|
||
assert_eq!(persisted.token, "second-private-token-second-private-token");
|
||
let names = fs::read_dir(&config_dir)
|
||
.expect("list endpoint directory")
|
||
.map(|entry| {
|
||
entry
|
||
.expect("endpoint directory entry")
|
||
.file_name()
|
||
.to_string_lossy()
|
||
.into_owned()
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(names, vec![EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME]);
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let mode = fs::metadata(&path)
|
||
.expect("endpoint metadata")
|
||
.permissions()
|
||
.mode()
|
||
& 0o777;
|
||
assert_eq!(mode, 0o600);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
crate::secure_windows_game_creator_path_for_current_user(&path, false, false)
|
||
.expect("endpoint owner must match TokenUser SID");
|
||
}
|
||
|
||
#[test]
|
||
fn runner_child_exit_is_reported_without_waiting_for_start_timeout() {
|
||
let directory = unique_test_directory();
|
||
let config_dir = crate::prepare_game_creator_runtime_config_dir(&directory.0.join("appdata"))
|
||
.expect("prepare private runner AppData");
|
||
#[cfg(windows)]
|
||
let mut child = std::process::Command::new("cmd.exe")
|
||
.args(["/D", "/C", "exit", "/B", "7"])
|
||
.spawn()
|
||
.expect("spawn immediately failing child");
|
||
#[cfg(unix)]
|
||
let mut child = std::process::Command::new("/bin/sh")
|
||
.args(["-c", "exit 7"])
|
||
.spawn()
|
||
.expect("spawn immediately failing child");
|
||
|
||
let started = Instant::now();
|
||
let error = match wait_for_external_agent_runner(&config_dir, &mut child, &"a".repeat(64)) {
|
||
Ok(_) => panic!("exited child must fail runner startup"),
|
||
Err(error) => error,
|
||
};
|
||
|
||
assert!(error.contains("在就绪前退出"));
|
||
assert!(
|
||
started.elapsed() < Duration::from_secs(2),
|
||
"exited child must not wait for the full startup deadline"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn public_status_never_serializes_endpoint_token() {
|
||
let secret = "status-private-token-status-private-token";
|
||
let endpoint = test_endpoint(secret, "status-boot-id", 30303);
|
||
let status = ExternalAgentRunnerStatus::from_endpoint(&endpoint, true);
|
||
let serialized = serde_json::to_string(&status).expect("serialize runner status");
|
||
|
||
assert!(serialized.contains("status-boot-id"));
|
||
assert!(serialized.contains("30303"));
|
||
assert!(!serialized.contains(secret));
|
||
assert!(!serialized.contains("\"token\""));
|
||
}
|