From 4e8662e87c6e948a4d1f0c20f96c914d61ed463a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 16:46:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=20AGC=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=80=80=E5=BD=B9=E8=BF=90=E8=A1=8C=E6=97=B6=E9=81=97?= =?UTF-8?q?=E7=95=99=E7=9A=84=E7=BC=96=E8=AF=91=E5=91=8A=E8=AD=A6=EF=BC=88?= =?UTF-8?q?session=20record=20/=20=E5=86=85=E5=AE=B9=20diff=20/=20ledger?= =?UTF-8?q?=20=E5=86=99=E5=85=A5=E5=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - process_session:删除退役的 record 模型与 io/persistence/lifecycle 模块及 30 例专属测试,保留 WindowsProcessJob、process_session_bridge 与 Runner 退出用的会话收尾入口。 - project/checkpoint:删除退役的本地项目内容 diff 实现与 3 例专属测试;3 例链接/敏感路径安全用例剥离 diff 断言后保留。 - project/agent_db:ledger 写入/校验层已无生产调用方、仅安全断言使用,27 项按 cfg(test) 收敛保留 68 例覆盖,4 项真死删除。 - project/*:真死条目删除,测试独占条目 cfg(test),仅生产入口条目按调用方条件 cfg(not(test));project.rs 丢弃失效 re-export/导入。 - runner/tests.rs:删除已无引用的 crate 导入块。 --- .../src-tauri/src/process_session.rs | 9 - .../src-tauri/src/process_session/io.rs | 370 ---- .../src/process_session/lifecycle.rs | 1652 --------------- .../src-tauri/src/process_session/model.rs | 293 +-- .../src/process_session/persistence.rs | 629 ------ .../src-tauri/src/process_session/recovery.rs | 147 +- .../src-tauri/src/process_session/tests.rs | 1819 ----------------- .../tests/owner_fixture_cleanup.rs | 127 -- .../src-tauri/src/project.rs | 5 +- .../src-tauri/src/project/agent_db.rs | 331 +-- .../src-tauri/src/project/asset_rename.rs | 28 +- .../src-tauri/src/project/checkpoint.rs | 306 --- .../src/project/checkpoint/security_tests.rs | 159 +- .../src-tauri/src/project/conversation.rs | 136 +- .../src-tauri/src/project/filesystem.rs | 4 + .../src-tauri/src/project/manifest.rs | 304 +-- .../src-tauri/src/project/verification.rs | 34 - .../src-tauri/src/runner/tests.rs | 5 - 18 files changed, 56 insertions(+), 6302 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session/persistence.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs index d35d657e6..9b3a15145 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs @@ -1,22 +1,13 @@ use super::*; -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use sha2::{Digest, Sha256}; use std::collections::HashMap; -use std::sync::Condvar; #[cfg(target_os = "linux")] use crate::process_session_bridge::*; -mod io; -mod lifecycle; mod model; -mod persistence; mod recovery; -pub(crate) use io::*; -pub(crate) use lifecycle::*; pub(crate) use model::*; -pub(crate) use persistence::*; pub(crate) use recovery::*; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs deleted file mode 100644 index 04a74476d..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs +++ /dev/null @@ -1,370 +0,0 @@ -use super::*; - -pub(super) fn live_process_session( - process_id: &str, -) -> Result>, String> { - validate_process_id(process_id)?; - Ok(process_session_registry() - .lock() - .map_err(|_| "process session registry 锁已损坏".to_string())? - .sessions - .get(process_id) - .cloned()) -} - -pub(super) fn poll_result_from_output( - process_id: &str, - output: &str, - state: &ProcessOutputState, - sandbox_backend: &str, - sandbox_mode: &str, - network_access: &str, - sandbox_profile_version: &str, - sandbox_establishment: &str, - target_exec: &str, - launch_failure_kind: Option<&str>, - cursor: Option<&str>, - max_chars: usize, -) -> Result { - let offset = parse_process_session_cursor(process_id, cursor, output)?; - let end = output[offset..] - .char_indices() - .nth(max_chars) - .map(|(index, _)| offset + index) - .unwrap_or(output.len()); - let next_cursor = process_session_cursor(process_id, end); - Ok(ProcessSessionPollResult { - process_id: process_id.to_string(), - status: state.status.clone(), - output: output[offset..end].to_string(), - cursor: process_session_cursor(process_id, offset), - next_cursor, - has_more: end < output.len(), - stdin_open: state.stdin_open, - exit_code: state.exit_code, - signal: state.signal.clone(), - output_bytes: output.len(), - output_sha256: format!("{:x}", Sha256::digest(output.as_bytes())), - source_changed: state.source_changed, - needs_reconciliation: state.needs_reconciliation, - sandbox_backend: sandbox_backend.to_string(), - sandbox_mode: sandbox_mode.to_string(), - network_access: network_access.to_string(), - sandbox_profile_version: sandbox_profile_version.to_string(), - sandbox_establishment: sandbox_establishment.to_string(), - target_exec: target_exec.to_string(), - launch_failure_kind: launch_failure_kind.map(str::to_string), - }) -} - -pub(crate) fn poll_process_session_at( - root: &Path, - identity: &ProcessSessionIdentity, - process_id: &str, - cursor: Option<&str>, - max_chars: Option, - wait_ms: Option, -) -> Result { - validate_process_session_identity(identity)?; - let max_chars = max_chars - .unwrap_or(PROCESS_SESSION_DEFAULT_POLL_CHARS) - .min(PROCESS_SESSION_MAX_POLL_CHARS); - let wait_ms = wait_ms.unwrap_or(0).min(PROCESS_SESSION_MAX_POLL_WAIT_MS); - if let Some(live) = live_process_session(process_id)? { - if live.root != root || live.identity != *identity { - return Err("process session 不属于当前 Agent run".to_string()); - } - let mut output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; - let initial_offset = parse_process_session_cursor(process_id, cursor, &output.text)?; - if wait_ms > 0 && initial_offset == output.text.len() && output.status == "running" { - let waited = live - .output_changed - .wait_timeout(output, Duration::from_millis(wait_ms)) - .map_err(|_| "process session output 锁已损坏".to_string())?; - output = waited.0; - } - return poll_result_from_output( - process_id, - &output.text, - &output, - &live.sandbox_backend, - &live.sandbox_mode, - &live.network_access, - &live.sandbox_profile_version, - &live.sandbox_establishment, - &live.target_exec, - output.launch_failure_kind.as_deref(), - cursor, - max_chars, - ); - } - - let mut record = read_process_session_record(root, process_id)? - .ok_or_else(|| "process session 不存在".to_string())?; - validate_process_session_access(&record, identity)?; - if matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) && record.owner_boot_id != process_session_boot_id() - { - reconcile_stale_active_process_session(&mut record); - write_process_session_record(root, &record)?; - } - let transcript = if let Some(output_ref) = record.output_ref.as_deref() { - read_agent_runtime_json_sidecar_with_max_bytes::( - root, - output_ref, - "Agent Runtime process transcript", - PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, - )? - } else { - None - }; - if let Some(transcript) = &transcript { - if let Err(error) = validate_process_session_transcript(transcript, &record) { - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); - let _ = write_process_session_record(root, &record); - return Err(error); - } - } - let output = transcript - .as_ref() - .map(|value| value.output.as_str()) - .unwrap_or_default(); - let state = ProcessOutputState { - text: output.to_string(), - status: record.status, - exit_code: record.exit_code, - signal: record.signal, - stdin_open: record.stdin_open, - reader_finished: true, - output_limit_exceeded: false, - source_fingerprint_after: record.source_fingerprint_after, - source_changed: record.source_changed, - needs_reconciliation: record.needs_reconciliation, - launch_failure_kind: record.launch_failure_kind.clone(), - }; - poll_result_from_output( - process_id, - output, - &state, - &record.sandbox_backend, - &record.sandbox_mode, - &record.network_access, - &record.sandbox_profile_version, - &record.sandbox_establishment, - &record.target_exec, - record.launch_failure_kind.as_deref(), - cursor, - max_chars, - ) -} - -pub(crate) fn write_process_session_stdin_at( - root: &Path, - identity: &ProcessSessionIdentity, - process_id: &str, - data: &str, - append_newline: bool, - eof: bool, -) -> Result { - write_process_session_stdin_at_with_after_write( - root, - identity, - process_id, - data, - append_newline, - eof, - |_| {}, - ) -} - -pub(super) fn write_process_session_stdin_at_with_after_write( - root: &Path, - identity: &ProcessSessionIdentity, - process_id: &str, - data: &str, - append_newline: bool, - eof: bool, - after_write: F, -) -> Result -where - F: FnOnce(&LiveProcessSession), -{ - validate_process_session_identity(identity)?; - let live = live_process_session(process_id)? - .ok_or_else(|| "process session 不在当前 Runner 中运行".to_string())?; - if live.root != root || live.identity != *identity { - return Err("process session 不属于当前 Agent run".to_string()); - } - let mut bytes = data.as_bytes().to_vec(); - if append_newline { - #[cfg(windows)] - bytes.extend_from_slice(b"\r\n"); - #[cfg(not(windows))] - bytes.push(b'\n'); - } - if bytes.len() > PROCESS_SESSION_MAX_STDIN_BYTES { - return Err(format!( - "command.stdin 单次最多写入 {PROCESS_SESSION_MAX_STDIN_BYTES} 字节" - )); - } - if bytes.iter().any(|byte| *byte == 0) { - return Err("command.stdin 不接受 NUL 或二进制正文".to_string()); - } - let content_sha256 = format!("{:x}", Sha256::digest(&bytes)); - let mut writer = live - .writer - .lock() - .map_err(|_| "process session stdin 锁已损坏".to_string())?; - if live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())? - .status - != "running" - { - return Err("process session 已进入终态".to_string()); - } - if !bytes.is_empty() { - let stream = writer - .as_mut() - .ok_or_else(|| "process session stdin 已关闭".to_string())?; - stream - .write_all(&bytes) - .and_then(|()| stream.flush()) - .map_err(|error| format!("写入 process session stdin 失败:{error}"))?; - } - if eof { - writer.take(); - } - drop(writer); - after_write(&live); - let mut output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; - if eof || output.status != "running" { - output.stdin_open = false; - } - let record = process_session_record_from_live(&live, &output); - if let Err(error) = write_process_session_record(root, &record) { - output.status = "needs-reconciliation".to_string(); - output.needs_reconciliation = true; - output.stdin_open = false; - let reconciliation = process_session_record_from_live(&live, &output); - let _ = write_process_session_record(root, &reconciliation); - let _ = live.control.send(ProcessControl::Terminate); - return Err(format!( - "command.stdin 已写入但状态无法落盘,需要人工核对:{error}" - )); - } - Ok(ProcessSessionStdinResult { - process_id: process_id.to_string(), - bytes_written: data.len() + usize::from(append_newline), - content_sha256, - stdin_open: output.stdin_open, - eof, - sandbox_backend: live.sandbox_backend.clone(), - sandbox_mode: live.sandbox_mode.clone(), - network_access: live.network_access.clone(), - sandbox_profile_version: live.sandbox_profile_version.clone(), - }) -} - -pub(crate) fn terminate_process_session_at( - root: &Path, - identity: &ProcessSessionIdentity, - process_id: &str, - cursor: Option<&str>, -) -> Result { - validate_process_session_identity(identity)?; - if let Some(live) = live_process_session(process_id)? { - if live.root != root || live.identity != *identity { - return Err("process session 不属于当前 Agent run".to_string()); - } - let running = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())? - .status - == "running"; - if running { - live.control - .send(ProcessControl::Terminate) - .map_err(|_| "process session 监督线程已结束".to_string())?; - let mut output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; - let deadline = std::time::Instant::now() - + Duration::from_millis(PROCESS_SESSION_TERMINATE_GRACE_MS + 1_500); - while output.status == "running" && std::time::Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let waited = live - .output_changed - .wait_timeout(output, remaining.min(Duration::from_millis(100))) - .map_err(|_| "process session output 锁已损坏".to_string())?; - output = waited.0; - } - } - let mut result = - poll_process_session_at(root, identity, process_id, cursor, Some(1), Some(0))?; - let cursor_offset = result - .cursor - .rsplit_once(':') - .and_then(|(_, offset)| offset.parse::().ok()) - .ok_or_else(|| "command.terminate 返回了无效 cursor".to_string())?; - result.output.clear(); - result.next_cursor = result.cursor.clone(); - result.has_more = cursor_offset < result.output_bytes; - return Ok(result); - } - let mut result = poll_process_session_at(root, identity, process_id, cursor, Some(1), Some(0))?; - let cursor_offset = result - .cursor - .rsplit_once(':') - .and_then(|(_, offset)| offset.parse::().ok()) - .ok_or_else(|| "command.terminate 返回了无效 cursor".to_string())?; - result.output.clear(); - result.next_cursor = result.cursor.clone(); - result.has_more = cursor_offset < result.output_bytes; - Ok(result) -} - -pub(crate) fn mark_process_session_start_audit_failure_at( - root: &Path, - process_id: &str, - error: &str, -) -> Result<(), String> { - if let Some(live) = live_process_session(process_id)? { - if live.root != root { - return Err("process session 不属于当前项目".to_string()); - } - mark_process_session_reconciliation( - &live, - &format!("command.start audit persistence failed: {error}"), - ); - if let Ok(mut output) = live.output.lock() { - output.launch_failure_kind = Some("start-audit-failed".to_string()); - } - let _ = live.control.send(ProcessControl::Terminate); - } - let mut record = read_process_session_record(root, process_id)? - .ok_or_else(|| "command.start audit 失败后 process record 缺失".to_string())?; - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - record.launch_failure_kind = Some("start-audit-failed".to_string()); - record.signal = Some(redact_agent_runtime_project_paths(root, error, 240)); - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); - write_process_session_record(root, &record) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs deleted file mode 100644 index 23b7fc096..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ /dev/null @@ -1,1652 +0,0 @@ -use super::*; - -pub(crate) fn validate_process_session_command_spec( - spec: &ProjectCommandSpec, -) -> Result<(), String> { - const DETACH_ARGUMENTS: &[&str] = &[ - "--background", - "--daemon", - "--daemonize", - "--detach", - "--fork", - ]; - if spec.arguments.iter().any(|argument| { - let argument = argument.trim().to_ascii_lowercase(); - DETACH_ARGUMENTS.contains(&argument.as_str()) - }) { - return Err("command.start 不允许 daemonize、detach、fork 或 background 参数".to_string()); - } - if spec.program == "npm" - && spec.arguments.first().map(String::as_str) == Some("run") - && spec.arguments.len() >= 2 - { - let package_path = spec.cwd.join("package.json"); - let metadata = fs::metadata(&package_path) - .map_err(|error| format!("command.start 无法读取 package.json:{error}"))?; - if !metadata.is_file() || metadata.len() > 256 * 1024 { - return Err("command.start package.json 必须是 256 KiB 内的普通文件".to_string()); - } - let package = fs::read_to_string(&package_path) - .map_err(|error| format!("command.start 无法读取 package.json:{error}"))?; - let package = serde_json::from_str::(&package) - .map_err(|error| format!("command.start package.json JSON 无效:{error}"))?; - let script_name = &spec.arguments[1]; - let script = package - .get("scripts") - .and_then(|value| value.get(script_name)) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("command.start npm script 不存在:{script_name}"))?; - let normalized = script.to_ascii_lowercase(); - if [ - "nohup", "setsid", "disown", "start /b", "--detach", "--daemon", - ] - .iter() - .any(|marker| normalized.contains(marker)) - || normalized.trim_end().ends_with('&') - { - return Err("command.start npm script 包含已知脱离 Runner 的启动方式".to_string()); - } - } - Ok(()) -} - -pub(super) fn process_session_command_builder( - launch: &ProjectCommandLaunchSpec, - #[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer, -) -> Result { - #[cfg(windows)] - let is_npm_launch = launch - .executable - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) - || launch.arguments.first().is_some_and(|argument| { - std::path::Path::new(argument) - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")) - }); - #[cfg(target_os = "linux")] - let mut command = { - let current_executable = std::env::current_exe() - .map_err(|error| format!("定位 process session child wrapper 失败:{error}"))?; - let mut command = CommandBuilder::new(current_executable); - #[cfg(not(test))] - { - command.arg(PROCESS_SESSION_CHILD_MODE); - } - #[cfg(test)] - { - command.args([ - "--exact", - "process_session::tests::process_session_child_wrapper_fixture", - "--nocapture", - "--test-threads=1", - ]); - } - command - }; - #[cfg(not(target_os = "linux"))] - let mut command = { - #[cfg(windows)] - { - if launch - .executable - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd")) - { - let npm_directory = launch - .executable - .parent() - .ok_or_else(|| "command.start 无法定位 Windows npm 安装目录".to_string())?; - let node_executable = npm_directory.join("node.exe"); - let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js"); - if !node_executable.is_file() || !npm_cli.is_file() { - return Err( - "command.start Windows npm 安装缺少 node.exe 或 npm-cli.js".to_string() - ); - } - let node_executable = node_executable.to_string_lossy(); - let npm_cli = npm_cli.to_string_lossy(); - let mut command = CommandBuilder::new( - node_executable - .strip_prefix(r"\\?\") - .unwrap_or(&node_executable), - ); - command.arg(npm_cli.strip_prefix(r"\\?\").unwrap_or(&npm_cli)); - command.args(&launch.arguments); - command - } else { - let mut command = CommandBuilder::new(&launch.executable); - command.args(&launch.arguments); - command - } - } - #[cfg(not(windows))] - { - let mut command = CommandBuilder::new(&launch.executable); - command.args(&launch.arguments); - command - } - }; - #[cfg(windows)] - { - let cwd = launch.cwd.to_string_lossy(); - command.cwd(cwd.strip_prefix(r"\\?\").unwrap_or(&cwd)); - } - #[cfg(not(windows))] - command.cwd(&launch.cwd); - command.env_clear(); - #[cfg(not(target_os = "linux"))] - for (name, value) in &launch.environment { - command.env(name, value); - } - #[cfg(windows)] - if is_npm_launch { - let node_executable = launch.executable.to_string_lossy(); - let node_executable = node_executable - .strip_prefix(r"\\?\") - .unwrap_or(&node_executable); - command.env("npm_node_execpath", node_executable); - command.env("NODE", node_executable); - command.env("npm_config_node_gyp", ""); - // Windows 环境变量名不区分大小写。先移除继承的拼写,避免 - // CommandBuilder 更新值后仍保留 `ComSpec` 而隐藏 npm 的小写键。 - command.env_remove("ComSpec"); - command.env("npm_config_script_shell", r"C:\Windows\System32\cmd.exe"); - } - #[cfg(target_os = "linux")] - { - command.env( - PROCESS_SESSION_OWNER_PID_ENV, - std::process::id().to_string(), - ); - command.env(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV, bridge.endpoint()); - command.env(PROCESS_SESSION_BRIDGE_NONCE_ENV, bridge.nonce_hex()); - } - Ok(command) -} - -pub(crate) fn validate_process_session_start_preflight_at( - root: &Path, - identity: &ProcessSessionIdentity, - spec: &ProjectCommandSpec, -) -> Result<(), String> { - validate_process_session_identity(identity)?; - if identity.project_id != game_creator_agent_runtime_context_project_id(root)? { - return Err("command.start projectId 与当前项目不匹配".to_string()); - } - validate_process_session_command_spec(spec)?; - if find_existing_start_action_record(root, identity)?.is_some() { - return Ok(()); - } - let records = active_process_session_records_at(root, None, None)?; - #[cfg(target_os = "linux")] - let pending = pending_process_launch_registry() - .lock() - .map_err(|_| "pending process launch registry 锁已损坏".to_string())? - .launches - .values() - .filter(|launch| launch.root == root) - .cloned() - .collect::>(); - if let Some(record) = records - .iter() - .find(|record| record.needs_reconciliation || record.status == "needs-reconciliation") - { - return Err(format!( - "项目存在待人工核对的进程会话 {},禁止启动新会话", - record.process_id - )); - } - #[cfg(target_os = "linux")] - let pending_project_count = pending.len(); - #[cfg(not(target_os = "linux"))] - let pending_project_count = 0; - if records.len().saturating_add(pending_project_count) >= PROCESS_SESSION_MAX_PER_PROJECT { - return Err(format!( - "当前项目最多同时运行 {PROCESS_SESSION_MAX_PER_PROJECT} 个 process session" - )); - } - let agent_count = records - .iter() - .filter(|record| record.agent_id == identity.agent_id) - .count(); - #[cfg(target_os = "linux")] - let agent_count = agent_count.saturating_add( - pending - .iter() - .filter(|launch| launch.agent_id == identity.agent_id) - .count(), - ); - if agent_count >= PROCESS_SESSION_MAX_PER_AGENT { - return Err(format!( - "当前 Agent 最多同时运行 {PROCESS_SESSION_MAX_PER_AGENT} 个 process session" - )); - } - Ok(()) -} - -#[cfg(test)] -pub(crate) fn start_process_session_at( - root: &Path, - identity: ProcessSessionIdentity, - spec: &ProjectCommandSpec, - source_fingerprint_before: String, -) -> Result { - let launch = - prepare_project_command_launch_spec(root, spec).map_err(|error| error.to_string())?; - start_prepared_process_session_at( - root, - identity, - spec, - &launch, - source_fingerprint_before, - || Ok(()), - ) - .map_err(|error| error.to_string()) -} - -pub(crate) fn start_prepared_process_session_at( - root: &Path, - identity: ProcessSessionIdentity, - spec: &ProjectCommandSpec, - launch: &ProjectCommandLaunchSpec, - source_fingerprint_before: String, - durable_commit: F, -) -> Result -where - F: FnOnce() -> Result<(), String>, -{ - #[cfg(target_os = "linux")] - { - start_linux_process_session_at( - root, - identity, - spec, - launch, - source_fingerprint_before, - durable_commit, - ) - } - #[cfg(not(target_os = "linux"))] - { - start_legacy_process_session_at( - root, - identity, - spec, - launch, - source_fingerprint_before, - durable_commit, - ) - } -} - -#[cfg(target_os = "linux")] -fn start_linux_process_session_at( - root: &Path, - identity: ProcessSessionIdentity, - spec: &ProjectCommandSpec, - launch: &ProjectCommandLaunchSpec, - source_fingerprint_before: String, - durable_commit: F, -) -> Result -where - F: FnOnce() -> Result<(), String>, -{ - validate_process_session_start_preflight_at(root, &identity, spec) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - if let Some(mut existing) = find_existing_start_action_record(root, &identity) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? - { - if matches!( - existing.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) { - if existing.owner_boot_id == process_session_boot_id() - && live_process_session(&existing.process_id) - .map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) - })? - .is_some() - { - return poll_process_session_at( - root, - &identity, - &existing.process_id, - None, - Some(0), - Some(0), - ) - .map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) - }); - } - reconcile_stale_active_process_session(&mut existing); - write_process_session_record(root, &existing).map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::AuditLog, - format!("command.start 旧启动状态无法写入 reconciliation:{error}"), - ) - })?; - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - "command.start 已提交启动但缺少当前 Runner 句柄,禁止自动重放", - )); - } - return poll_process_session_at( - root, - &identity, - &existing.process_id, - None, - Some(0), - Some(0), - ) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)); - } - - let process_id = process_session_id(&identity); - let command_id = format!( - "cmd-{}", - &format!( - "{:x}", - Sha256::digest( - serde_json::to_vec(&serde_json::json!({ - "program": spec.program, - "args": spec.arguments, - "cwd": spec.cwd_relative, - })) - .unwrap_or_default() - ) - )[..24] - ); - let _pending_launch = reserve_pending_process_launch(root, &identity.agent_id, &process_id) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let bridge_server = ProcessSessionBridgeServer::bind() - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let pair = native_pty_system() - .openpty(PtySize { - rows: 30, - cols: 120, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Preflight, - format!("创建 command.start PTY 失败:{error}"), - ) - })?; - let reader = pair.master.try_clone_reader().map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Preflight, - format!("克隆 command.start PTY reader 失败:{error}"), - ) - })?; - let writer = pair.master.take_writer().map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Preflight, - format!("取得 command.start PTY writer 失败:{error}"), - ) - })?; - let command = process_session_command_builder(launch, &bridge_server) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let mut child = pair.slave.spawn_command(command).map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Spawn, - format!("启动 command.start wrapper 失败:{error}"), - ) - })?; - drop(pair.slave); - let process_group_leader = pair.master.process_group_leader().or_else(|| { - child - .process_id() - .and_then(|value| i32::try_from(value).ok()) - }); - let peer_pid = child.process_id().ok_or_else(|| { - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - "command.start wrapper 缺少 pid", - termination, - ) - })?; - let process_group_leader = Some( - process_group_leader - .or_else(|| i32::try_from(peer_pid).ok()) - .ok_or_else(|| { - let termination = terminate_pending_process_session_child(&mut child, None); - project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - "command.start wrapper 缺少进程组身份", - termination, - ) - })?, - ); - activate_pending_process_launch( - &process_id, - process_group_leader.expect("process group leader validated"), - ) - .map_err(|error| { - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - error, - termination, - ) - })?; - let mut bridge = match bridge_server.accept(peer_pid, Duration::from_secs(3)) { - Ok(bridge) => bridge, - Err(error) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - error, - termination, - )); - } - }; - if let Err(error) = bridge.send_prepare(launch, spec) { - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - error, - termination, - )); - } - match bridge.wait_sandbox_ready(Duration::from_secs(4)) { - Ok(ProcessSessionSandboxReadyVerdict::Ready) => {} - Ok(ProcessSessionSandboxReadyVerdict::Failed { failure_kind }) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - format!("command.start sandbox ready 前失败:{failure_kind}"), - termination, - )); - } - Err(error) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::Preflight, - error, - termination, - )); - } - } - - let sandbox_ready_at = unix_timestamp(); - let mut durable_record = initial_process_session_record( - &identity, - &process_id, - &command_id, - spec, - Some(launch), - &source_fingerprint_before, - "launching", - ); - durable_record.sandbox_establishment = "established".to_string(); - durable_record.target_exec = "not-attempted".to_string(); - durable_record.started_at = sandbox_ready_at; - durable_record.sandbox_ready_at = Some(sandbox_ready_at); - if let Err(error) = durable_commit() { - let _ = bridge.abort_launch(); - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::DurableCommit, - error, - termination, - )); - } - if let Err(error) = write_process_session_record(root, &durable_record) { - let _ = bridge.abort_launch(); - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - return Err(project_command_error_after_pending_termination( - ProjectCommandErrorStage::DurableCommit, - format!("写入 command.start commit record 失败:{error}"), - termination, - )); - } - - if let Err(error) = bridge.commit_exec() { - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - persist_process_session_launch_unknown(root, &mut durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - format!("{error};{termination}"), - )); - } - let exec = bridge.wait_exec(Duration::from_secs(4)); - match exec { - Ok(ProcessSessionExecVerdict::TargetExecFailed { errno }) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - let needs_reconciliation = !termination.confirmed; - mark_process_session_launch_record( - &mut durable_record, - if needs_reconciliation { - "needs-reconciliation" - } else { - "failed" - }, - "failed", - Some("target-exec-failed"), - needs_reconciliation, - ); - write_process_session_record(root, &durable_record).map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::AuditLog, - format!( - "command.start target exec 失败后终态无法落盘:errno={errno};{termination};{error}" - ), - ) - })?; - if needs_reconciliation { - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - format!( - "command.start target exec 失败但 wrapper 回收无法确认:errno={errno};{termination}" - ), - )); - } - return poll_process_session_at(root, &identity, &process_id, None, Some(0), Some(0)) - .map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error) - }); - } - Ok(ProcessSessionExecVerdict::LaunchUnknown) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - persist_process_session_launch_unknown(root, &mut durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - format!("process session wrapper 报告 launch unknown;{termination}"), - )); - } - Err(error) => { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - persist_process_session_launch_unknown(root, &mut durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - format!("{error};{termination}"), - )); - } - Ok(ProcessSessionExecVerdict::Established) => {} - } - - let exec_established_at = unix_timestamp(); - durable_record.status = "running".to_string(); - durable_record.target_exec = "established".to_string(); - durable_record.exec_established_at = Some(exec_established_at); - durable_record.stdin_open = true; - durable_record.updated_at = exec_established_at; - if let Err(error) = write_process_session_record(root, &durable_record) { - let termination = terminate_pending_process_session_child(&mut child, process_group_leader); - persist_process_session_launch_unknown(root, &mut durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - format!("command.start exec-established 状态无法落盘:{error};{termination}"), - )); - } - - let (control_tx, control_rx) = std::sync::mpsc::channel(); - let live = Arc::new(LiveProcessSession { - root: root.to_path_buf(), - identity, - process_id: process_id.clone(), - command_id, - program: spec.program.clone(), - cwd: spec.cwd_relative.clone(), - sandbox_backend: launch.sandbox_backend.clone(), - sandbox_mode: launch.sandbox_mode.clone(), - network_access: launch.network_access.clone(), - sandbox_profile_version: launch.sandbox_profile_version.clone(), - sandbox_establishment: "established".to_string(), - target_exec: "established".to_string(), - sandbox_ready_at: Some(sandbox_ready_at), - exec_established_at: Some(exec_established_at), - source_fingerprint_before, - started_at: durable_record.started_at, - output: Mutex::new(ProcessOutputState::running()), - output_changed: Condvar::new(), - writer: Mutex::new(Some(writer)), - master: Mutex::new(Some(pair.master)), - control: control_tx, - }); - process_session_registry() - .lock() - .map_err(|_| { - let termination = - terminate_pending_process_session_child(&mut child, process_group_leader); - persist_process_session_launch_unknown(root, &mut durable_record); - ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - format!("process session registry 锁已损坏;{termination}"), - ) - })? - .sessions - .insert(process_id.clone(), Arc::clone(&live)); - - let reader_live = Arc::clone(&live); - thread::spawn(move || drain_process_session_output(reader_live, reader)); - let supervisor_live = Arc::clone(&live); - let timeout_seconds = spec.timeout_seconds; - thread::spawn(move || { - supervise_process_session( - supervisor_live, - &mut child, - control_rx, - timeout_seconds, - process_group_leader, - bridge, - ) - }); - - poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0)) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) -} - -#[cfg(target_os = "linux")] -struct PendingProcessTermination { - confirmed: bool, - summary: String, -} - -#[cfg(target_os = "linux")] -impl std::fmt::Display for PendingProcessTermination { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.summary) - } -} - -#[cfg(target_os = "linux")] -fn terminate_pending_process_session_child( - child: &mut Box, - process_group_leader: Option, -) -> PendingProcessTermination { - let group = process_group_leader.filter(|value| *value > 0); - let group_result = group.map(|group| { - let result = unsafe { libc::kill(-group, libc::SIGKILL) }; - if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - }); - let _ = child.kill(); - let wait = child.wait(); - let (confirmed, summary) = match (group_result, wait) { - (Some(Ok(())), Ok(_)) => (true, "wrapper 进程组已终止并回收".to_string()), - (None, Ok(_)) => (false, "wrapper 主进程已回收但缺少进程组身份".to_string()), - (Some(Err(error)), Ok(_)) => ( - false, - format!("wrapper 主进程已回收但进程组终止失败:{error}"), - ), - (_, Err(error)) => (false, format!("wrapper 进程回收失败:{error}")), - }; - PendingProcessTermination { confirmed, summary } -} - -#[cfg(target_os = "linux")] -fn project_command_error_after_pending_termination( - confirmed_stage: ProjectCommandErrorStage, - message: impl Into, - termination: PendingProcessTermination, -) -> ProjectCommandError { - let stage = if termination.confirmed { - confirmed_stage - } else { - ProjectCommandErrorStage::LaunchUnknown - }; - ProjectCommandError::new(stage, format!("{};{termination}", message.into())) -} - -#[cfg(target_os = "linux")] -fn mark_process_session_launch_record( - record: &mut ProcessSessionRecord, - status: &str, - target_exec: &str, - launch_failure_kind: Option<&str>, - needs_reconciliation: bool, -) { - record.status = status.to_string(); - record.target_exec = target_exec.to_string(); - record.launch_failure_kind = launch_failure_kind.map(str::to_string); - record.stdin_open = false; - record.needs_reconciliation = needs_reconciliation; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); -} - -#[cfg(target_os = "linux")] -fn persist_process_session_launch_unknown(root: &Path, record: &mut ProcessSessionRecord) { - mark_process_session_launch_record( - record, - "needs-reconciliation", - "unknown", - Some("launch-unknown"), - true, - ); - let _ = write_process_session_record(root, record); -} - -#[cfg(not(target_os = "linux"))] -fn start_legacy_process_session_at( - root: &Path, - identity: ProcessSessionIdentity, - spec: &ProjectCommandSpec, - launch: &ProjectCommandLaunchSpec, - source_fingerprint_before: String, - durable_commit: F, -) -> Result -where - F: FnOnce() -> Result<(), String>, -{ - validate_process_session_start_preflight_at(root, &identity, spec) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - if let Some(existing) = find_existing_start_action_record(root, &identity) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? - { - if matches!( - existing.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) { - if existing.owner_boot_id == process_session_boot_id() - && live_process_session(&existing.process_id) - .map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) - })? - .is_some() - { - return poll_process_session_at( - root, - &identity, - &existing.process_id, - None, - Some(0), - Some(0), - ) - .map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) - }); - } - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - "command.start 已进入可能启动阶段但缺少当前 Runner 句柄,禁止自动重放", - )); - } - return poll_process_session_at( - root, - &identity, - &existing.process_id, - None, - Some(0), - Some(0), - ) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)); - } - - durable_commit().map_err(|error| { - ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error) - })?; - - let process_id = process_session_id(&identity); - let command_id = format!( - "cmd-{}", - &format!( - "{:x}", - Sha256::digest( - serde_json::to_vec(&serde_json::json!({ - "program": spec.program, - "args": spec.arguments, - "cwd": spec.cwd_relative, - })) - .unwrap_or_default() - ) - )[..24] - ); - - let mut durable_record = initial_process_session_record( - &identity, - &process_id, - &command_id, - spec, - Some(launch), - &source_fingerprint_before, - "prepared", - ); - write_process_session_record(root, &durable_record) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; - durable_record.status = "launching".to_string(); - durable_record.updated_at = unix_timestamp(); - write_process_session_record(root, &durable_record) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; - - let pair = native_pty_system() - .openpty(PtySize { - rows: 30, - cols: 120, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("创建 command.start PTY 失败:{error}"), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let command = process_session_command_builder(launch) - .map_err(|error| process_session_launch_failed(root, &mut durable_record, error)) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - let mut child = pair - .slave - .spawn_command(command) - .map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("启动 command.start {} 失败:{error}", spec.program), - ) - }) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; - drop(pair.slave); - let reader = pair.master.try_clone_reader().map_err(|error| { - let _ = child.kill(); - let _ = child.wait(); - ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - process_session_launch_failed( - root, - &mut durable_record, - format!("克隆 command.start PTY reader 失败:{error}"), - ), - ) - })?; - let writer = pair.master.take_writer().map_err(|error| { - let _ = child.kill(); - let _ = child.wait(); - ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - process_session_launch_failed( - root, - &mut durable_record, - format!("取得 command.start PTY writer 失败:{error}"), - ), - ) - })?; - #[cfg(windows)] - let windows_job = match WindowsProcessJob::assign(child.as_ref()) { - Ok(job) => job, - Err(error) => { - let _ = child.kill(); - let _ = child.wait(); - durable_record.status = "needs-reconciliation".to_string(); - durable_record.sandbox_establishment = "unknown".to_string(); - durable_record.target_exec = "unknown".to_string(); - durable_record.launch_failure_kind = Some("launch-unknown".to_string()); - durable_record.needs_reconciliation = true; - durable_record.terminal_at = Some(unix_timestamp()); - durable_record.updated_at = unix_timestamp(); - let _ = write_process_session_record(root, &durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - format!("command.start 已创建进程但无法纳入 Windows Job Object:{error}"), - )); - } - }; - #[cfg(unix)] - let process_group_leader = pair.master.process_group_leader().or_else(|| { - child - .process_id() - .and_then(|value| i32::try_from(value).ok()) - }); - #[cfg(not(unix))] - let process_group_leader: Option = None; - - let (control_tx, control_rx) = std::sync::mpsc::channel(); - let launch_established_at = unix_timestamp(); - let live = Arc::new(LiveProcessSession { - root: root.to_path_buf(), - identity, - process_id: process_id.clone(), - command_id, - program: spec.program.clone(), - cwd: spec.cwd_relative.clone(), - sandbox_backend: launch.sandbox_backend.clone(), - sandbox_mode: launch.sandbox_mode.clone(), - network_access: launch.network_access.clone(), - sandbox_profile_version: launch.sandbox_profile_version.clone(), - sandbox_establishment: "established".to_string(), - target_exec: "established".to_string(), - sandbox_ready_at: Some(launch_established_at), - exec_established_at: Some(launch_established_at), - source_fingerprint_before, - started_at: launch_established_at, - output: Mutex::new(ProcessOutputState::running()), - output_changed: Condvar::new(), - writer: Mutex::new(Some(writer)), - master: Mutex::new(Some(pair.master)), - #[cfg(windows)] - job: Mutex::new(Some(windows_job)), - control: control_tx, - }); - - let record = { - let output = live.output.lock().map_err(|_| { - ProjectCommandError::new( - ProjectCommandErrorStage::Execution, - "process session output 锁已损坏", - ) - })?; - process_session_record_from_live(&live, &output) - }; - if let Err(error) = write_process_session_record(root, &record) { - let _ = child.kill(); - let _ = child.wait(); - durable_record.status = "needs-reconciliation".to_string(); - durable_record.sandbox_establishment = "unknown".to_string(); - durable_record.target_exec = "unknown".to_string(); - durable_record.launch_failure_kind = Some("launch-unknown".to_string()); - durable_record.needs_reconciliation = true; - durable_record.terminal_at = Some(unix_timestamp()); - durable_record.updated_at = unix_timestamp(); - let _ = write_process_session_record(root, &durable_record); - return Err(ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - format!("command.start 已启动但 running 状态无法落盘,需要人工核对:{error}"), - )); - } - process_session_registry() - .lock() - .map_err(|_| { - ProjectCommandError::new( - ProjectCommandErrorStage::LaunchUnknown, - "process session registry 锁已损坏", - ) - })? - .sessions - .insert(process_id.clone(), Arc::clone(&live)); - - let reader_live = Arc::clone(&live); - thread::spawn(move || drain_process_session_output(reader_live, reader)); - let supervisor_live = Arc::clone(&live); - let timeout_seconds = spec.timeout_seconds; - thread::spawn(move || { - supervise_process_session( - supervisor_live, - &mut child, - control_rx, - timeout_seconds, - process_group_leader, - ) - }); - - poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0)) - .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) -} - -fn append_process_output_line(live: &LiveProcessSession, line: &[u8]) -> bool { - let text = String::from_utf8_lossy(line); - let mut sanitized = redact_agent_runtime_project_paths( - &live.root, - &sanitize_project_verification_output(&text), - PROCESS_SESSION_MAX_PENDING_LINE_BYTES, - ); - if matches!(line.last(), Some(b'\n' | b'\r')) && !sanitized.ends_with('\n') { - sanitized.push('\n'); - } - let mut output = match live.output.lock() { - Ok(output) => output, - Err(_) => return false, - }; - if output.text.len().saturating_add(sanitized.len()) > PROCESS_SESSION_MAX_OUTPUT_BYTES { - output.output_limit_exceeded = true; - output.status = "output-limit-exceeded".to_string(); - output.stdin_open = false; - live.output_changed.notify_all(); - return false; - } - output.text.push_str(&sanitized); - live.output_changed.notify_all(); - drop(output); - if persist_live_process_snapshot(live).is_err() { - if let Ok(mut output) = live.output.lock() { - output.status = "needs-reconciliation".to_string(); - output.needs_reconciliation = true; - output.stdin_open = false; - live.output_changed.notify_all(); - } - let _ = live.control.send(ProcessControl::Terminate); - } - true -} - -fn persist_live_process_snapshot(live: &LiveProcessSession) -> Result<(), String> { - let output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; - let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes())); - let transcript = ProcessSessionTranscript { - schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(), - project_id: live.identity.project_id.clone(), - agent_id: live.identity.agent_id.clone(), - task_id: live.identity.task_id.clone(), - conversation_session_id: live.identity.conversation_session_id.clone(), - run_id: live.identity.run_id.clone(), - start_action_id: live.identity.start_action_id.clone(), - start_action_fingerprint: live.identity.start_action_fingerprint.clone(), - process_id: live.process_id.clone(), - output: output.text.clone(), - output_sha256, - output_bytes: output.text.len(), - updated_at: unix_timestamp(), - }; - let record = process_session_record_from_live(live, &output); - write_agent_runtime_json_sidecar_with_max_bytes( - &live.root, - &process_session_transcript_relative_path(&live.process_id), - "Agent Runtime process transcript", - &transcript, - PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, - )?; - write_process_session_record(&live.root, &record) -} - -#[derive(Default)] -pub(super) struct AnsiStripper { - state: u8, -} - -impl AnsiStripper { - pub(super) fn push(&mut self, byte: u8, visible: &mut Vec) { - match self.state { - 0 if byte == 0x1b => self.state = 1, - 0 if byte == b'\n' || byte == b'\r' || byte == b'\t' || byte >= 0x20 => { - visible.push(byte) - } - 1 if byte == b'[' => self.state = 2, - 1 if matches!(byte, b']' | b'P' | b'X' | b'^' | b'_') => self.state = 3, - 1 => self.state = 0, - 2 if (0x40..=0x7e).contains(&byte) => self.state = 0, - 2 => {} - 3 if byte == 0x07 => self.state = 0, - 3 if byte == 0x1b => self.state = 4, - 3 => {} - 4 if byte == b'\\' => self.state = 0, - 4 if byte == 0x1b => {} - 4 => self.state = 3, - _ => self.state = 0, - } - } -} - -#[cfg(windows)] -#[derive(Default)] -pub(super) struct AnsiTerminalRepositionDetector { - state: u8, -} - -#[cfg(windows)] -impl AnsiTerminalRepositionDetector { - pub(super) fn push(&mut self, byte: u8) -> bool { - match self.state { - 0 if byte == 0x1b => self.state = 1, - 1 if byte == b'[' => self.state = 2, - 1 => self.state = 0, - 2 if (0x40..=0x7e).contains(&byte) => { - self.state = 0; - return matches!(byte, b'A'..=b'H' | b'f'); - } - 2 => {} - _ => self.state = 0, - } - false - } -} - -fn drain_process_session_output( - live: Arc, - mut reader: Box, -) { - let mut buffer = [0u8; 4096]; - let mut pending = Vec::new(); - let mut pending_logical_line_bytes = 0usize; - let mut ansi = AnsiStripper::default(); - let mut output_limit = false; - #[cfg(windows)] - let mut conpty_cursor_query_match = 0usize; - #[cfg(windows)] - let mut conpty_cursor_replied = false; - #[cfg(windows)] - let mut terminal_reposition = AnsiTerminalRepositionDetector::default(); - #[cfg(windows)] - let mut conpty_soft_wrap = false; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(read) => { - for byte in &buffer[..read] { - #[cfg(windows)] - { - const CONPTY_CURSOR_QUERY: &[u8] = b"\x1b[6n"; - if !conpty_cursor_replied - && *byte == CONPTY_CURSOR_QUERY[conpty_cursor_query_match] - { - conpty_cursor_query_match += 1; - if conpty_cursor_query_match == CONPTY_CURSOR_QUERY.len() { - conpty_cursor_query_match = 0; - let reply_result = live - .writer - .lock() - .map_err(|_| "process session stdin 锁已损坏".to_string()) - .and_then(|mut writer| { - let Some(writer) = writer.as_mut() else { - // 终止线程会先关闭 stdin;此时 ConPTY 可能仍把启动期 - // 光标查询交给 reader。进程树已经进入收束阶段,无需再 - // 把无法回复查询升级成 needs-reconciliation。 - return Ok(()); - }; - writer - .write_all(b"\x1b[1;1R") - .and_then(|()| writer.flush()) - .map_err(|error| { - format!("回复 Windows ConPTY 光标查询失败:{error}") - }) - }); - if let Err(error) = reply_result { - if let Ok(mut output) = live.output.lock() { - output.status = "failed".to_string(); - output.needs_reconciliation = true; - output.stdin_open = false; - let detail = format!( - "\n\n" - ); - if output.text.len().saturating_add(detail.len()) - <= PROCESS_SESSION_MAX_OUTPUT_BYTES - { - output.text.push_str(&detail); - } - live.output_changed.notify_all(); - } - let _ = live.control.send(ProcessControl::Terminate); - return; - } - conpty_cursor_replied = true; - } - } else if !conpty_cursor_replied { - conpty_cursor_query_match = - usize::from(*byte == CONPTY_CURSOR_QUERY[0]); - } - } - #[cfg(windows)] - let ends_terminal_reposition = terminal_reposition.push(*byte); - let before = pending.len(); - ansi.push(*byte, &mut pending); - let visible_bytes = pending.len().saturating_sub(before); - if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) { - pending_logical_line_bytes = - pending_logical_line_bytes.saturating_add(visible_bytes); - if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { - output_limit = true; - break; - } - } - #[cfg(windows)] - if ends_terminal_reposition && !pending.is_empty() { - pending.push(b'\n'); - if !append_process_output_line(&live, &pending) { - output_limit = true; - break; - } - pending.clear(); - continue; - } - if pending.len() == before { - continue; - } - if matches!(pending.last(), Some(b'\n' | b'\r')) { - if !append_process_output_line(&live, &pending) { - output_limit = true; - break; - } - #[cfg(windows)] - { - // ConPTY materializes an automatic terminal-width wrap as CR/LF. - // It is a display boundary, not an application line terminator, so - // it must not reset the logical-line safety limit. A real short line - // still resets at CR; the immediately following LF preserves that - // decision. - const PROCESS_SESSION_PTY_COLS: usize = 120; - match pending.last() { - Some(b'\r') => { - conpty_soft_wrap = - pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS; - if !conpty_soft_wrap { - pending_logical_line_bytes = 0; - } - } - Some(b'\n') => { - if !conpty_soft_wrap { - pending_logical_line_bytes = 0; - } - conpty_soft_wrap = false; - } - _ => {} - } - } - #[cfg(not(windows))] - { - pending_logical_line_bytes = 0; - } - pending.clear(); - } - } - if output_limit { - if let Ok(mut output) = live.output.lock() { - output.output_limit_exceeded = true; - output.status = "output-limit-exceeded".to_string(); - output.stdin_open = false; - live.output_changed.notify_all(); - } - let _ = live.control.send(ProcessControl::OutputLimit); - break; - } - } - Err(error) => { - if let Ok(mut output) = live.output.lock() { - output.status = "failed".to_string(); - output.needs_reconciliation = true; - output.stdin_open = false; - let detail = format!("\n\n"); - if output.text.len().saturating_add(detail.len()) - <= PROCESS_SESSION_MAX_OUTPUT_BYTES - { - output.text.push_str(&detail); - } - live.output_changed.notify_all(); - } - let _ = live.control.send(ProcessControl::Terminate); - break; - } - } - } - if !pending.is_empty() && !output_limit { - let _ = append_process_output_line(&live, &pending); - } - if let Ok(mut output) = live.output.lock() { - output.reader_finished = true; - live.output_changed.notify_all(); - } -} - -fn supervise_process_session( - live: Arc, - child: &mut Box, - control_rx: std::sync::mpsc::Receiver, - timeout_seconds: u64, - #[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option, - #[cfg(target_os = "linux")] mut launch_bridge: ProcessSessionBridge, -) { - let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds); - let (terminal_status, exit_code, signal) = loop { - match child.try_wait() { - Ok(Some(status)) => { - #[cfg(target_os = "linux")] - let _ = &status; - #[cfg(target_os = "linux")] - let terminal = match launch_bridge.wait_terminal(Duration::from_secs(2)) { - Ok(ProcessSessionTerminalVerdict::Exited { code }) => { - ("exited".to_string(), Some(code), None) - } - Ok(ProcessSessionTerminalVerdict::Signaled { signal }) => { - ("exited".to_string(), None, Some(format!("signal-{signal}"))) - } - Ok(ProcessSessionTerminalVerdict::Unknown) => { - let error = "process session target terminal 无法确认".to_string(); - mark_process_session_reconciliation(&live, &error); - ("needs-reconciliation".to_string(), None, Some(error)) - } - Err(error) => { - mark_process_session_reconciliation(&live, &error); - ("needs-reconciliation".to_string(), None, Some(error)) - } - }; - if let Err(error) = terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - #[cfg(target_os = "linux")] - break terminal; - #[cfg(not(target_os = "linux"))] - break ( - "exited".to_string(), - i32::try_from(status.exit_code()).ok(), - status.signal().map(str::to_string), - ); - } - Ok(None) => {} - Err(error) => { - let wait_error = format!("wait failed: {error}"); - if let Err(termination_error) = terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - let detail = format!("{wait_error}; {termination_error}"); - mark_process_session_reconciliation(&live, &detail); - break ("needs-reconciliation".to_string(), None, Some(detail)); - } - break ("failed".to_string(), None, Some(wait_error)); - } - } - - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let wait = remaining.min(Duration::from_millis(50)); - match control_rx.recv_timeout(wait) { - Ok(ProcessControl::Terminate) => { - match terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - false, - ) { - Ok(()) => break ("terminated".to_string(), None, None), - Err(error) => { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - } - } - Ok(ProcessControl::OutputLimit) => { - match terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - Ok(()) => break ("output-limit-exceeded".to_string(), None, None), - Err(error) => { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - } - } - Ok(ProcessControl::Shutdown) => { - match terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - Ok(()) => { - break ( - "terminated".to_string(), - None, - Some("runner-shutdown".to_string()), - ); - } - Err(error) => { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - } - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - match terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - Ok(()) => { - break ( - "terminated".to_string(), - None, - Some("control-disconnected".to_string()), - ); - } - Err(error) => { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - } - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} - } - if std::time::Instant::now() >= deadline { - match terminate_process_session_child( - &live, - child, - process_group_leader, - #[cfg(target_os = "linux")] - &mut launch_bridge, - true, - ) { - Ok(()) => break ("timed-out".to_string(), None, None), - Err(error) => { - mark_process_session_reconciliation(&live, &error); - break ("needs-reconciliation".to_string(), None, Some(error)); - } - } - } - }; - finalize_live_process_session(&live, &terminal_status, exit_code, signal); -} - -pub(super) fn mark_process_session_reconciliation(live: &LiveProcessSession, error: &str) { - if let Ok(mut output) = live.output.lock() { - output.status = "needs-reconciliation".to_string(); - output.needs_reconciliation = true; - output.stdin_open = false; - output.signal = Some(redact_agent_runtime_project_paths(&live.root, error, 300)); - live.output_changed.notify_all(); - } -} - -fn terminate_process_session_child( - live: &LiveProcessSession, - child: &mut Box, - #[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option, - #[cfg(target_os = "linux")] launch_bridge: &mut ProcessSessionBridge, - #[cfg_attr(windows, allow(unused_variables))] force: bool, -) -> Result<(), String> { - #[cfg(not(windows))] - let _ = live; - let mut child_reaped = child.try_wait().ok().flatten().is_some(); - let tree_contained; - #[cfg(windows)] - { - tree_contained = live - .job - .lock() - .map_err(|_| "Windows process session Job Object 锁已损坏".to_string())? - .as_ref() - .ok_or_else(|| "Windows process session 缺少 Job Object".to_string())? - .terminate() - .is_ok(); - } - #[cfg(unix)] - { - tree_contained = if let Some(group) = process_group_leader.filter(|value| *value > 0) { - #[cfg(target_os = "linux")] - if !force && !child_reaped { - if let Err(error) = launch_bridge.terminate_target() { - match child.try_wait() { - Ok(Some(_)) => child_reaped = true, - Ok(None) => return Err(error), - Err(wait_error) => { - return Err(format!("{error};检查 wrapper 终态失败:{wait_error}")); - } - } - } - } - #[cfg(all(unix, not(target_os = "linux")))] - if !force && !child_reaped { - unsafe { - libc::kill(-group, libc::SIGTERM); - } - } - if !force && !child_reaped { - let deadline = std::time::Instant::now() - + Duration::from_millis(PROCESS_SESSION_TERMINATE_GRACE_MS); - while std::time::Instant::now() < deadline { - if !child_reaped { - if let Ok(Some(_)) = child.try_wait() { - child_reaped = true; - break; - } - } - thread::sleep(Duration::from_millis(25)); - } - } - let killed = unsafe { libc::kill(-group, libc::SIGKILL) }; - if killed == 0 { - true - } else { - std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) && child_reaped - } - } else { - false - }; - } - #[cfg(not(any(unix, windows)))] - { - tree_contained = false; - } - if !child_reaped { - let _ = child.kill(); - match child.wait() { - Ok(_) => child_reaped = true, - Err(error) => { - return Err(format!("process session child 回收失败:{error}")); - } - } - } - if !tree_contained { - return Err("process session 进程树终止结果无法确认".to_string()); - } - if child_reaped { - Ok(()) - } else { - Err("process session child 尚未回收".to_string()) - } -} - -fn finalize_live_process_session( - live: &Arc, - status: &str, - exit_code: Option, - signal: Option, -) { - if let Ok(mut writer) = live.writer.lock() { - writer.take(); - } - if let Ok(mut master) = live.master.lock() { - master.take(); - } - #[cfg(windows)] - if let Ok(mut job) = live.job.lock() { - job.take(); - } - let source_fingerprint_after = project_command_source_fingerprint(&live.root).ok(); - let mut output = match live.output.lock() { - Ok(output) => output, - Err(_) => return, - }; - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while !output.reader_finished && std::time::Instant::now() < deadline { - let wait = live - .output_changed - .wait_timeout(output, Duration::from_millis(25)); - let Ok((next, _)) = wait else { - return; - }; - output = next; - } - output.status = if output.needs_reconciliation { - "needs-reconciliation".to_string() - } else if output.output_limit_exceeded { - "output-limit-exceeded".to_string() - } else if output.status == "failed" { - "failed".to_string() - } else { - status.to_string() - }; - output.exit_code = exit_code; - output.signal = signal; - output.stdin_open = false; - output.source_changed = source_fingerprint_after - .as_ref() - .map(|after| after != &live.source_fingerprint_before); - output.source_fingerprint_after = source_fingerprint_after; - if output.source_fingerprint_after.is_none() || !output.reader_finished { - output.needs_reconciliation = true; - } - - let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes())); - let transcript = ProcessSessionTranscript { - schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(), - project_id: live.identity.project_id.clone(), - agent_id: live.identity.agent_id.clone(), - task_id: live.identity.task_id.clone(), - conversation_session_id: live.identity.conversation_session_id.clone(), - run_id: live.identity.run_id.clone(), - start_action_id: live.identity.start_action_id.clone(), - start_action_fingerprint: live.identity.start_action_fingerprint.clone(), - process_id: live.process_id.clone(), - output: output.text.clone(), - output_sha256, - output_bytes: output.text.len(), - updated_at: unix_timestamp(), - }; - let transcript_result = write_agent_runtime_json_sidecar_with_max_bytes( - &live.root, - &process_session_transcript_relative_path(&live.process_id), - "Agent Runtime process transcript", - &transcript, - PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, - ); - if transcript_result.is_err() { - output.needs_reconciliation = true; - } - let record = process_session_record_from_live(live, &output); - let record_persisted = write_process_session_record(&live.root, &record).is_ok(); - if !record_persisted { - output.needs_reconciliation = true; - } - let needs_reconciliation = output.needs_reconciliation; - // 只有 live registry 已收束后才能发布可信终态。等待 `output_changed` 的调用方 - // 可能立即执行 run 级取消清理,而 registry 中的任何 live 条目都会被视为未完成会话。 - if record_persisted && !needs_reconciliation { - if let Ok(mut registry) = process_session_registry().lock() { - registry.sessions.remove(&live.process_id); - } - } - live.output_changed.notify_all(); - drop(output); -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index f8d02848c..410d3a6c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -1,205 +1,24 @@ use super::*; -pub(super) const PROCESS_SESSION_SCHEMA_VERSION: &str = "3"; -pub(super) const PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION: &str = "2"; -pub(super) const PROCESS_SESSION_CURSOR_VERSION: &str = "v1"; -pub(super) const PROCESS_SESSION_MAX_PER_PROJECT: usize = 4; -pub(super) const PROCESS_SESSION_MAX_PER_AGENT: usize = 2; -pub(super) const PROCESS_SESSION_MAX_OUTPUT_BYTES: usize = 256 * 1024; -pub(super) const PROCESS_SESSION_MAX_PENDING_LINE_BYTES: usize = 16 * 1024; -pub(super) const PROCESS_SESSION_MAX_STDIN_BYTES: usize = 8 * 1024; -pub(super) const PROCESS_SESSION_DEFAULT_POLL_CHARS: usize = 8_000; -pub(super) const PROCESS_SESSION_MAX_POLL_CHARS: usize = 16_000; -pub(super) const PROCESS_SESSION_MAX_POLL_WAIT_MS: u64 = 30_000; -pub(super) const PROCESS_SESSION_RECORD_MAX_BYTES: usize = 32 * 1024; -pub(super) const PROCESS_SESSION_TRANSCRIPT_MAX_BYTES: usize = 320 * 1024; -pub(super) const PROCESS_SESSION_TERMINATE_GRACE_MS: u64 = 800; #[cfg(target_os = "linux")] pub(super) const PROCESS_SESSION_OWNER_PID_ENV: &str = "GENARRATIVE_PROCESS_SESSION_OWNER_PID"; #[cfg(target_os = "linux")] pub(super) const PROCESS_SESSION_CHILD_MODE: &str = "--process-session-child"; -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ProcessSessionIdentity { - pub(crate) project_id: String, - pub(crate) agent_id: String, - pub(crate) task_id: String, - pub(crate) conversation_session_id: String, - pub(crate) run_id: String, - pub(crate) start_action_id: String, - pub(crate) start_action_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct ProcessSessionRecord { - pub(crate) schema_version: String, - pub(crate) project_id: String, - pub(crate) agent_id: String, - pub(crate) task_id: String, - pub(crate) conversation_session_id: String, - pub(crate) run_id: String, - pub(crate) start_action_id: String, - pub(crate) start_action_fingerprint: String, - pub(crate) process_id: String, - pub(crate) owner_boot_id: String, - pub(crate) command_id: String, - pub(crate) program: String, - pub(crate) cwd: String, - #[serde(default)] - pub(crate) sandbox_backend: String, - #[serde(default)] - pub(crate) sandbox_mode: String, - #[serde(default)] - pub(crate) network_access: String, - #[serde(default)] - pub(crate) sandbox_profile_version: String, - #[serde(default)] - pub(crate) sandbox_establishment: String, - #[serde(default)] - pub(crate) target_exec: String, - #[serde(default)] - pub(crate) launch_failure_kind: Option, - #[serde(default)] - pub(crate) sandbox_ready_at: Option, - #[serde(default)] - pub(crate) exec_established_at: Option, - pub(crate) status: String, - pub(crate) exit_code: Option, - pub(crate) signal: Option, - pub(crate) stdin_open: bool, - pub(crate) output_bytes: usize, - pub(crate) output_sha256: String, - pub(crate) output_ref: Option, - pub(crate) source_fingerprint_before: String, - pub(crate) source_fingerprint_after: Option, - pub(crate) source_changed: Option, - pub(crate) needs_reconciliation: bool, - pub(crate) started_at: u64, - pub(crate) terminal_at: Option, - pub(crate) updated_at: u64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(super) struct ProcessSessionTranscript { - pub(super) schema_version: String, - pub(super) project_id: String, - pub(super) agent_id: String, - pub(super) task_id: String, - pub(super) conversation_session_id: String, - pub(super) run_id: String, - pub(super) start_action_id: String, - pub(super) start_action_fingerprint: String, - pub(super) process_id: String, - pub(super) output: String, - pub(super) output_sha256: String, - pub(super) output_bytes: usize, - pub(super) updated_at: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ProcessSessionPollResult { - pub(crate) process_id: String, - pub(crate) status: String, - pub(crate) output: String, - pub(crate) cursor: String, - pub(crate) next_cursor: String, - pub(crate) has_more: bool, - pub(crate) stdin_open: bool, - pub(crate) exit_code: Option, - pub(crate) signal: Option, - pub(crate) output_bytes: usize, - pub(crate) output_sha256: String, - pub(crate) source_changed: Option, - pub(crate) needs_reconciliation: bool, - pub(crate) sandbox_backend: String, - pub(crate) sandbox_mode: String, - pub(crate) network_access: String, - pub(crate) sandbox_profile_version: String, - pub(crate) sandbox_establishment: String, - pub(crate) target_exec: String, - pub(crate) launch_failure_kind: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct ProcessSessionStdinResult { - pub(crate) process_id: String, - pub(crate) bytes_written: usize, - pub(crate) content_sha256: String, - pub(crate) stdin_open: bool, - pub(crate) eof: bool, - pub(crate) sandbox_backend: String, - pub(crate) sandbox_mode: String, - pub(crate) network_access: String, - pub(crate) sandbox_profile_version: String, -} - +#[cfg(not(test))] #[derive(Debug)] pub(super) struct ProcessOutputState { - pub(super) text: String, pub(super) status: String, - pub(super) exit_code: Option, - pub(super) signal: Option, - pub(super) stdin_open: bool, - pub(super) reader_finished: bool, - pub(super) output_limit_exceeded: bool, - pub(super) source_fingerprint_after: Option, - pub(super) source_changed: Option, - pub(super) needs_reconciliation: bool, - pub(super) launch_failure_kind: Option, -} - -impl ProcessOutputState { - pub(super) fn running() -> Self { - Self { - text: String::new(), - status: "running".to_string(), - exit_code: None, - signal: None, - stdin_open: true, - reader_finished: false, - output_limit_exceeded: false, - source_fingerprint_after: None, - source_changed: None, - needs_reconciliation: false, - launch_failure_kind: None, - } - } } #[derive(Debug)] pub(super) enum ProcessControl { - Terminate, - OutputLimit, Shutdown, } pub(super) struct LiveProcessSession { - pub(super) root: PathBuf, - pub(super) identity: ProcessSessionIdentity, - pub(super) process_id: String, - pub(super) command_id: String, - pub(super) program: String, - pub(super) cwd: String, - pub(super) sandbox_backend: String, - pub(super) sandbox_mode: String, - pub(super) network_access: String, - pub(super) sandbox_profile_version: String, - pub(super) sandbox_establishment: String, - pub(super) target_exec: String, - pub(super) sandbox_ready_at: Option, - pub(super) exec_established_at: Option, - pub(super) source_fingerprint_before: String, - pub(super) started_at: u64, + #[cfg(not(test))] pub(super) output: Mutex, - pub(super) output_changed: Condvar, - pub(super) writer: Mutex>>, - pub(super) master: Mutex>>, - #[cfg(windows)] - pub(super) job: Mutex>, pub(super) control: std::sync::mpsc::Sender, } @@ -215,14 +34,6 @@ unsafe impl Sync for WindowsProcessJob {} #[cfg(windows)] impl WindowsProcessJob { - pub(super) fn assign(child: &dyn Child) -> Result { - let process = child - .as_raw_handle() - .ok_or_else(|| "command.start Windows child 缺少 process handle".to_string())? - as windows_sys::Win32::Foundation::HANDLE; - Self::assign_handle(process) - } - pub(crate) fn assign_std(child: &std::process::Child) -> Result { use std::os::windows::io::AsRawHandle; Self::assign_handle( @@ -491,17 +302,6 @@ impl Drop for WindowsProcessJob { } } -impl std::fmt::Debug for LiveProcessSession { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("LiveProcessSession") - .field("process_id", &self.process_id) - .field("agent_id", &self.identity.agent_id) - .field("run_id", &self.identity.run_id) - .finish_non_exhaustive() - } -} - #[derive(Default)] pub(super) struct ProcessSessionRegistry { pub(super) sessions: HashMap>, @@ -510,8 +310,6 @@ pub(super) struct ProcessSessionRegistry { #[cfg(target_os = "linux")] #[derive(Clone, Debug)] pub(super) struct PendingProcessLaunch { - pub(super) root: PathBuf, - pub(super) agent_id: String, pub(super) process_group_leader: Option, pub(super) shutdown_requested: bool, } @@ -522,21 +320,8 @@ pub(super) struct PendingProcessLaunchRegistry { pub(super) launches: HashMap, } -#[cfg(target_os = "linux")] -pub(super) struct PendingProcessLaunchGuard { - process_id: String, -} - -#[cfg(target_os = "linux")] -impl Drop for PendingProcessLaunchGuard { - fn drop(&mut self) { - if let Ok(mut registry) = pending_process_launch_registry().lock() { - registry.launches.remove(&self.process_id); - } - } -} - static PROCESS_SESSION_REGISTRY: OnceLock> = OnceLock::new(); +#[cfg(not(test))] static PROCESS_SESSION_BOOT_ID: OnceLock = OnceLock::new(); #[cfg(target_os = "linux")] static PENDING_PROCESS_LAUNCH_REGISTRY: OnceLock> = @@ -552,77 +337,7 @@ pub(super) fn pending_process_launch_registry() -> &'static Mutex Result { - let mut registry = pending_process_launch_registry() - .lock() - .map_err(|_| "pending process launch registry 锁已损坏".to_string())?; - if registry.launches.contains_key(process_id) { - return Err("command.start pending launch 身份冲突".to_string()); - } - registry.launches.insert( - process_id.to_string(), - PendingProcessLaunch { - root: root.to_path_buf(), - agent_id: agent_id.to_string(), - process_group_leader: None, - shutdown_requested: false, - }, - ); - Ok(PendingProcessLaunchGuard { - process_id: process_id.to_string(), - }) -} - -#[cfg(target_os = "linux")] -pub(super) fn activate_pending_process_launch( - process_id: &str, - process_group_leader: i32, -) -> Result<(), String> { - if process_group_leader <= 1 { - return Err("command.start wrapper 进程组身份无效".to_string()); - } - let mut registry = pending_process_launch_registry() - .lock() - .map_err(|_| "pending process launch registry 锁已损坏".to_string())?; - let launch = registry - .launches - .get_mut(process_id) - .ok_or_else(|| "command.start pending launch reservation 缺失".to_string())?; - launch.process_group_leader = Some(process_group_leader); - if launch.shutdown_requested { - unsafe { - libc::kill(-process_group_leader, libc::SIGKILL); - } - return Err("Runner shutdown 已取消 pending process launch".to_string()); - } - Ok(()) -} - -pub(crate) fn process_session_boot_id() -> &'static str { - PROCESS_SESSION_BOOT_ID - .get_or_init(|| { - let mut digest = Sha256::new(); - digest.update(std::process::id().to_le_bytes()); - digest.update( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - .to_le_bytes(), - ); - digest.update(unix_timestamp().to_le_bytes()); - let value = format!("{:x}", digest.finalize()); - format!("boot-{}", &value[..32]) - }) - .as_str() -} - -// Runner 启动时绑定 owner;共享的会话归属与清理逻辑仍参与测试。 +// Runner 启动时绑定 owner;该入口只由 Runner 生产接入(runner/server/desktop.rs)调用。 #[cfg(not(test))] pub(crate) fn initialize_process_session_boot_id(boot_id: &str) -> Result<(), String> { let boot_id = boot_id.trim(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/persistence.rs deleted file mode 100644 index 9e934533b..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/persistence.rs +++ /dev/null @@ -1,629 +0,0 @@ -use super::*; - -pub(super) fn validate_process_session_identity( - identity: &ProcessSessionIdentity, -) -> Result<(), String> { - for (label, value, max_chars) in [ - ("projectId", identity.project_id.as_str(), 160), - ("agentId", identity.agent_id.as_str(), 96), - ("taskId", identity.task_id.as_str(), 96), - ( - "conversationSessionId", - identity.conversation_session_id.as_str(), - 160, - ), - ("runId", identity.run_id.as_str(), 160), - ("startActionId", identity.start_action_id.as_str(), 160), - ] { - let trimmed = value.trim(); - if trimmed.is_empty() - || trimmed.chars().count() > max_chars - || trimmed.chars().any(|character| character.is_control()) - { - return Err(format!("command.start {label} 无效")); - } - } - if identity.start_action_fingerprint.len() != 64 - || !identity - .start_action_fingerprint - .bytes() - .all(|byte| byte.is_ascii_hexdigit()) - { - return Err("command.start action fingerprint 无效".to_string()); - } - Ok(()) -} - -pub(super) fn validate_process_id(process_id: &str) -> Result<(), String> { - if process_id.len() != 37 - || !process_id.starts_with("proc-") - || !process_id[5..].bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err("processId 格式无效".to_string()); - } - Ok(()) -} - -pub(super) fn process_session_id(identity: &ProcessSessionIdentity) -> String { - let payload = serde_json::to_vec(&serde_json::json!({ - "projectId": identity.project_id, - "agentId": identity.agent_id, - "taskId": identity.task_id, - "conversationSessionId": identity.conversation_session_id, - "runId": identity.run_id, - "startActionId": identity.start_action_id, - "startActionFingerprint": identity.start_action_fingerprint, - "ownerBootId": process_session_boot_id(), - })) - .unwrap_or_default(); - let value = format!("{:x}", Sha256::digest(payload)); - format!("proc-{}", &value[..32]) -} - -pub(super) fn process_session_record_relative_path(process_id: &str) -> String { - format!(".agent/runtime/process-sessions/{process_id}.json") -} - -pub(super) fn process_session_transcript_relative_path(process_id: &str) -> String { - format!(".agent/runtime/process-sessions/{process_id}.output.json") -} - -pub(super) fn process_session_cursor(process_id: &str, offset: usize) -> String { - format!("{PROCESS_SESSION_CURSOR_VERSION}:{process_id}:{offset}") -} - -pub(super) fn parse_process_session_cursor( - process_id: &str, - cursor: Option<&str>, - output: &str, -) -> Result { - let Some(cursor) = cursor.filter(|value| !value.trim().is_empty()) else { - return Ok(0); - }; - let mut parts = cursor.split(':'); - let version = parts.next().unwrap_or_default(); - let cursor_process_id = parts.next().unwrap_or_default(); - let offset = parts - .next() - .ok_or_else(|| "command.poll cursor 无效".to_string())? - .parse::() - .map_err(|_| "command.poll cursor offset 无效".to_string())?; - if parts.next().is_some() - || version != PROCESS_SESSION_CURSOR_VERSION - || cursor_process_id != process_id - || offset > output.len() - || !output.is_char_boundary(offset) - { - return Err("command.poll cursor 与当前进程输出不匹配".to_string()); - } - Ok(offset) -} - -pub(super) fn write_process_session_record( - root: &Path, - record: &ProcessSessionRecord, -) -> Result<(), String> { - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &process_session_record_relative_path(&record.process_id), - "Agent Runtime process session", - record, - PROCESS_SESSION_RECORD_MAX_BYTES, - ) -} - -pub(super) fn read_process_session_record( - root: &Path, - process_id: &str, -) -> Result, String> { - validate_process_id(process_id)?; - let mut record = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &process_session_record_relative_path(process_id), - "Agent Runtime process session", - PROCESS_SESSION_RECORD_MAX_BYTES, - )?; - if let Some(record) = record.as_mut() { - let normalized = normalize_process_session_record(record); - validate_process_session_record(root, record, process_id)?; - if normalized { - write_process_session_record(root, record)?; - } - } - Ok(record) -} - -pub(super) fn normalize_process_session_record(record: &mut ProcessSessionRecord) -> bool { - let legacy_schema = matches!(record.schema_version.as_str(), "1" | "2"); - if !legacy_schema { - return false; - } - if record.schema_version == "1" { - record.sandbox_backend = "legacy-unknown".to_string(); - record.sandbox_mode = "unknown".to_string(); - record.network_access = "unknown".to_string(); - record.sandbox_profile_version = "legacy-v1".to_string(); - } - let legacy_active = matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ); - record.schema_version = PROCESS_SESSION_SCHEMA_VERSION.to_string(); - record.sandbox_establishment = "unknown".to_string(); - record.target_exec = "unknown".to_string(); - record.launch_failure_kind = Some( - if legacy_active { - "legacy-active-record" - } else { - "legacy-record" - } - .to_string(), - ); - record.sandbox_ready_at = None; - record.exec_established_at = None; - if legacy_active { - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); - } - legacy_active -} - -pub(super) fn validate_process_session_record( - root: &Path, - record: &ProcessSessionRecord, - process_id: &str, -) -> Result<(), String> { - if record.schema_version != PROCESS_SESSION_SCHEMA_VERSION - || record.process_id != process_id - || record.project_id != game_creator_agent_runtime_context_project_id(root)? - || record.sandbox_backend.is_empty() - || record.sandbox_mode.is_empty() - || record.network_access.is_empty() - || record.sandbox_profile_version.is_empty() - || !matches!( - record.sandbox_establishment.as_str(), - "not-established" | "established" | "unknown" - ) - || !matches!( - record.target_exec.as_str(), - "not-attempted" | "established" | "failed" | "unknown" - ) - || record.launch_failure_kind.as_deref().is_some_and(|value| { - !matches!( - value, - "pre-exec-failed" - | "durable-commit-failed" - | "target-exec-failed" - | "launch-unknown" - | "legacy-active-record" - | "legacy-record" - | "start-audit-failed" - ) - }) - { - return Err("Agent Runtime process session 身份不匹配".to_string()); - } - validate_process_id(&record.process_id)?; - if !matches!( - record.status.as_str(), - "prepared" - | "launching" - | "running" - | "terminating" - | "exited" - | "terminated" - | "timed-out" - | "output-limit-exceeded" - | "needs-reconciliation" - | "failed" - ) { - return Err("Agent Runtime process session 状态无效".to_string()); - } - if matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) && record.terminal_at.is_some() - { - return Err("运行中的 process session 不应有 terminalAt".to_string()); - } - if !matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) && record.terminal_at.is_none() - { - return Err("终态 process session 缺少 terminalAt".to_string()); - } - let launch_state_valid = match record.launch_failure_kind.as_deref() { - None => match record.status.as_str() { - "prepared" => { - record.sandbox_establishment == "not-established" - && record.target_exec == "not-attempted" - && !record.needs_reconciliation - } - "launching" => { - matches!( - record.sandbox_establishment.as_str(), - "not-established" | "established" - ) && record.target_exec == "not-attempted" - && !record.needs_reconciliation - } - "running" | "terminating" => { - record.sandbox_establishment == "established" - && record.target_exec == "established" - && !record.needs_reconciliation - } - "needs-reconciliation" => { - record.sandbox_establishment == "established" - && record.target_exec == "established" - && record.needs_reconciliation - } - "exited" | "terminated" | "timed-out" | "output-limit-exceeded" | "failed" => { - record.sandbox_establishment == "established" && record.target_exec == "established" - } - _ => false, - }, - Some("pre-exec-failed") => { - record.status == "failed" - && record.sandbox_establishment == "not-established" - && record.target_exec == "not-attempted" - && !record.needs_reconciliation - } - Some("durable-commit-failed") => { - record.status == "failed" - && matches!( - record.sandbox_establishment.as_str(), - "not-established" | "established" - ) - && record.target_exec == "not-attempted" - && !record.needs_reconciliation - } - Some("target-exec-failed") => { - matches!(record.status.as_str(), "failed" | "needs-reconciliation") - && record.sandbox_establishment == "established" - && record.target_exec == "failed" - && (record.status == "needs-reconciliation") == record.needs_reconciliation - } - Some("launch-unknown") => { - record.status == "needs-reconciliation" - && record.needs_reconciliation - && record.target_exec == "unknown" - && matches!( - record.sandbox_establishment.as_str(), - "established" | "unknown" - ) - } - Some("legacy-active-record") => { - record.status == "needs-reconciliation" - && record.needs_reconciliation - && record.sandbox_establishment == "unknown" - && record.target_exec == "unknown" - } - Some("legacy-record") => { - !matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) && record.sandbox_establishment == "unknown" - && record.target_exec == "unknown" - && (record.status != "needs-reconciliation" || record.needs_reconciliation) - } - Some("start-audit-failed") => { - record.status == "needs-reconciliation" - && record.needs_reconciliation - && record.sandbox_establishment == "established" - && record.target_exec == "established" - } - Some(_) => false, - }; - if !launch_state_valid { - return Err("process session 可信 launch 状态组合无效".to_string()); - } - if record.started_at > record.updated_at - || record.terminal_at.is_some_and(|terminal_at| { - record.started_at > terminal_at || terminal_at > record.updated_at - }) - { - return Err("process session 生命周期时间顺序无效".to_string()); - } - match record.sandbox_establishment.as_str() { - "established" if record.sandbox_ready_at.is_none() => { - return Err("已建立的 process session sandbox 缺少 ready 时间".to_string()); - } - "not-established" | "unknown" if record.sandbox_ready_at.is_some() => { - return Err("未建立或未知的 process session sandbox 不应有 ready 时间".to_string()); - } - _ => {} - } - match record.target_exec.as_str() { - "established" if record.exec_established_at.is_none() => { - return Err("已建立的 process session target 缺少 exec 时间".to_string()); - } - "not-attempted" | "failed" | "unknown" if record.exec_established_at.is_some() => { - return Err("未建立的 process session target 不应有 exec 时间".to_string()); - } - _ => {} - } - if let Some(sandbox_ready_at) = record.sandbox_ready_at { - if record.started_at > sandbox_ready_at - || sandbox_ready_at > record.updated_at - || record - .terminal_at - .is_some_and(|terminal_at| sandbox_ready_at > terminal_at) - { - return Err("process session sandbox-ready 时间顺序无效".to_string()); - } - } - if let Some(exec_established_at) = record.exec_established_at { - if record - .sandbox_ready_at - .is_none_or(|sandbox_ready_at| sandbox_ready_at > exec_established_at) - || exec_established_at > record.updated_at - || record - .terminal_at - .is_some_and(|terminal_at| exec_established_at > terminal_at) - { - return Err("process session exec-established 时间顺序无效".to_string()); - } - } - Ok(()) -} - -pub(super) fn reconcile_stale_active_process_session(record: &mut ProcessSessionRecord) { - let previous_status = record.status.clone(); - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - if previous_status == "prepared" { - record.sandbox_establishment = "unknown".to_string(); - record.sandbox_ready_at = None; - record.target_exec = "unknown".to_string(); - record.exec_established_at = None; - record.launch_failure_kind = Some("launch-unknown".to_string()); - } else if previous_status == "launching" { - if record.sandbox_establishment != "established" { - record.sandbox_establishment = "unknown".to_string(); - record.sandbox_ready_at = None; - } - record.target_exec = "unknown".to_string(); - record.exec_established_at = None; - record.launch_failure_kind = Some("launch-unknown".to_string()); - } - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); -} - -pub(super) fn process_session_record_from_live( - live: &LiveProcessSession, - output: &ProcessOutputState, -) -> ProcessSessionRecord { - let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes())); - let terminal = output.status != "running"; - ProcessSessionRecord { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), - project_id: live.identity.project_id.clone(), - agent_id: live.identity.agent_id.clone(), - task_id: live.identity.task_id.clone(), - conversation_session_id: live.identity.conversation_session_id.clone(), - run_id: live.identity.run_id.clone(), - start_action_id: live.identity.start_action_id.clone(), - start_action_fingerprint: live.identity.start_action_fingerprint.clone(), - process_id: live.process_id.clone(), - owner_boot_id: process_session_boot_id().to_string(), - command_id: live.command_id.clone(), - program: live.program.clone(), - cwd: live.cwd.clone(), - sandbox_backend: live.sandbox_backend.clone(), - sandbox_mode: live.sandbox_mode.clone(), - network_access: live.network_access.clone(), - sandbox_profile_version: live.sandbox_profile_version.clone(), - sandbox_establishment: live.sandbox_establishment.clone(), - target_exec: live.target_exec.clone(), - launch_failure_kind: output.launch_failure_kind.clone(), - sandbox_ready_at: live.sandbox_ready_at, - exec_established_at: live.exec_established_at, - status: output.status.clone(), - exit_code: output.exit_code, - signal: output.signal.clone(), - stdin_open: output.stdin_open, - output_bytes: output.text.len(), - output_sha256, - output_ref: Some(process_session_transcript_relative_path(&live.process_id)), - source_fingerprint_before: live.source_fingerprint_before.clone(), - source_fingerprint_after: output.source_fingerprint_after.clone(), - source_changed: output.source_changed, - needs_reconciliation: output.needs_reconciliation, - started_at: live.started_at, - terminal_at: terminal.then(unix_timestamp), - updated_at: unix_timestamp(), - } -} - -pub(super) fn initial_process_session_record( - identity: &ProcessSessionIdentity, - process_id: &str, - command_id: &str, - spec: &ProjectCommandSpec, - launch: Option<&ProjectCommandLaunchSpec>, - source_fingerprint_before: &str, - status: &str, -) -> ProcessSessionRecord { - let now = unix_timestamp(); - let sandbox_backend = launch - .map(|value| value.sandbox_backend.clone()) - .unwrap_or_else(|| "test-unknown".to_string()); - let sandbox_mode = launch - .map(|value| value.sandbox_mode.clone()) - .unwrap_or_else(|| "unknown".to_string()); - let network_access = launch - .map(|value| value.network_access.clone()) - .unwrap_or_else(|| "unknown".to_string()); - let sandbox_profile_version = launch - .map(|value| value.sandbox_profile_version.clone()) - .unwrap_or_else(|| "test-v1".to_string()); - ProcessSessionRecord { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), - project_id: identity.project_id.clone(), - agent_id: identity.agent_id.clone(), - task_id: identity.task_id.clone(), - conversation_session_id: identity.conversation_session_id.clone(), - run_id: identity.run_id.clone(), - start_action_id: identity.start_action_id.clone(), - start_action_fingerprint: identity.start_action_fingerprint.clone(), - process_id: process_id.to_string(), - owner_boot_id: process_session_boot_id().to_string(), - command_id: command_id.to_string(), - program: spec.program.clone(), - cwd: spec.cwd_relative.clone(), - sandbox_backend, - sandbox_mode, - network_access, - sandbox_profile_version, - sandbox_establishment: "not-established".to_string(), - target_exec: "not-attempted".to_string(), - launch_failure_kind: None, - sandbox_ready_at: None, - exec_established_at: None, - status: status.to_string(), - exit_code: None, - signal: None, - stdin_open: false, - output_bytes: 0, - output_sha256: format!("{:x}", Sha256::digest([])), - output_ref: None, - source_fingerprint_before: source_fingerprint_before.to_string(), - source_fingerprint_after: None, - source_changed: None, - needs_reconciliation: false, - started_at: now, - terminal_at: None, - updated_at: now, - } -} - -#[cfg(not(target_os = "linux"))] -pub(super) fn process_session_launch_failed( - root: &Path, - record: &mut ProcessSessionRecord, - error: String, -) -> String { - record.status = "failed".to_string(); - record.target_exec = "not-attempted".to_string(); - record.launch_failure_kind = Some("pre-exec-failed".to_string()); - record.stdin_open = false; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); - match write_process_session_record(root, record) { - Ok(()) => error, - Err(record_error) => format!("{error};process session 失败终态无法落盘:{record_error}"), - } -} - -pub(super) fn validate_process_session_access( - record: &ProcessSessionRecord, - identity: &ProcessSessionIdentity, -) -> Result<(), String> { - if record.project_id != identity.project_id - || record.agent_id != identity.agent_id - || record.task_id != identity.task_id - || record.conversation_session_id != identity.conversation_session_id - || record.run_id != identity.run_id - { - return Err("process session 不属于当前 Agent run".to_string()); - } - Ok(()) -} - -pub(crate) fn process_session_identity_for_run_at( - root: &Path, - agent_id: &str, - task_id: &str, - conversation_session_id: &str, - run_id: &str, - process_id: &str, -) -> Result { - let record = read_process_session_record(root, process_id)? - .ok_or_else(|| "process session 不存在".to_string())?; - if record.agent_id != agent_id - || record.task_id != task_id - || record.conversation_session_id != conversation_session_id - || record.run_id != run_id - { - return Err("process session 不属于当前 Agent run".to_string()); - } - Ok(ProcessSessionIdentity { - project_id: record.project_id, - agent_id: record.agent_id, - task_id: record.task_id, - conversation_session_id: record.conversation_session_id, - run_id: record.run_id, - start_action_id: record.start_action_id, - start_action_fingerprint: record.start_action_fingerprint, - }) -} - -pub(super) fn validate_process_session_transcript( - transcript: &ProcessSessionTranscript, - record: &ProcessSessionRecord, -) -> Result<(), String> { - let output_sha256 = format!("{:x}", Sha256::digest(transcript.output.as_bytes())); - if !matches!(transcript.schema_version.as_str(), "1" | "2") - || transcript.project_id != record.project_id - || transcript.agent_id != record.agent_id - || transcript.task_id != record.task_id - || transcript.conversation_session_id != record.conversation_session_id - || transcript.run_id != record.run_id - || transcript.start_action_id != record.start_action_id - || transcript.start_action_fingerprint != record.start_action_fingerprint - || transcript.process_id != record.process_id - || transcript.output_bytes != transcript.output.len() - || transcript.output_sha256 != output_sha256 - || record.output_bytes != transcript.output_bytes - || record.output_sha256 != transcript.output_sha256 - { - return Err("Agent Runtime process transcript 身份或摘要不匹配".to_string()); - } - Ok(()) -} - -pub(super) fn find_existing_start_action_record( - root: &Path, - identity: &ProcessSessionIdentity, -) -> Result, String> { - let directory = root.join(".agent/runtime/process-sessions"); - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("读取 process session 目录失败:{error}")), - }; - for entry in entries { - let entry = entry.map_err(|error| format!("读取 process session 目录项失败:{error}"))?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - let Some(process_id) = name - .strip_suffix(".json") - .filter(|value| !value.ends_with(".output")) - else { - continue; - }; - if validate_process_id(process_id).is_err() { - continue; - } - let Some(record) = read_process_session_record(root, process_id)? else { - continue; - }; - if record.agent_id == identity.agent_id - && record.run_id == identity.run_id - && record.start_action_id == identity.start_action_id - { - if record.start_action_fingerprint != identity.start_action_fingerprint { - return Err("command.start action identity 冲突".to_string()); - } - return Ok(Some(record)); - } - } - Ok(None) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs index da3de5e8e..0d24d477c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs @@ -1,138 +1,5 @@ use super::*; -pub(crate) fn active_process_session_records_at( - root: &Path, - agent_id: Option<&str>, - run_id: Option<&str>, -) -> Result, String> { - let live_sessions = process_session_registry() - .lock() - .map_err(|_| "process session registry 锁已损坏".to_string())? - .sessions - .values() - .filter(|live| live.root == root) - .cloned() - .collect::>(); - let mut records = Vec::with_capacity(live_sessions.len()); - for live in live_sessions { - if agent_id.is_some_and(|value| value != live.identity.agent_id.as_str()) - || run_id.is_some_and(|value| value != live.identity.run_id.as_str()) - { - continue; - } - let output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; - records.push(process_session_record_from_live(&live, &output)); - } - let directory = root.join(".agent/runtime/process-sessions"); - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - records.sort_by(|left, right| left.started_at.cmp(&right.started_at)); - return Ok(records); - } - Err(error) => return Err(format!("读取 process session 目录失败:{error}")), - }; - for entry in entries { - let entry = entry.map_err(|error| format!("读取 process session 目录项失败:{error}"))?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - let Some(process_id) = name - .strip_suffix(".json") - .filter(|value| !value.ends_with(".output")) - else { - continue; - }; - if validate_process_id(process_id).is_err() { - continue; - } - if records.iter().any(|record| record.process_id == process_id) { - continue; - } - let Some(mut record) = read_process_session_record(root, process_id)? else { - continue; - }; - if matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" - ) && record.owner_boot_id != process_session_boot_id() - { - reconcile_stale_active_process_session(&mut record); - write_process_session_record(root, &record)?; - } - if (record.needs_reconciliation - || matches!( - record.status.as_str(), - "prepared" | "launching" | "running" | "terminating" | "needs-reconciliation" - )) - && agent_id.is_none_or(|value| value == record.agent_id) - && run_id.is_none_or(|value| value == record.run_id) - { - records.push(record); - } - } - records.sort_by(|left, right| left.started_at.cmp(&right.started_at)); - Ok(records) -} - -pub(crate) fn has_active_process_sessions_at(root: &Path) -> Result { - if !active_process_session_records_at(root, None, None)?.is_empty() { - return Ok(true); - } - #[cfg(target_os = "linux")] - { - return Ok(pending_process_launch_registry() - .lock() - .map_err(|_| "pending process launch registry 锁已损坏".to_string())? - .launches - .values() - .any(|launch| launch.root == root)); - } - #[cfg(not(target_os = "linux"))] - Ok(false) -} - -pub(crate) fn terminate_process_sessions_for_run_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result<(), String> { - let records = active_process_session_records_at(root, Some(agent_id), Some(run_id))?; - for record in &records { - if record.status == "needs-reconciliation" || record.needs_reconciliation { - return Err(format!( - "进程会话 {} 需要人工核对,不能把 run 标记为已取消", - record.process_id - )); - } - let identity = ProcessSessionIdentity { - project_id: record.project_id.clone(), - agent_id: record.agent_id.clone(), - task_id: record.task_id.clone(), - conversation_session_id: record.conversation_session_id.clone(), - run_id: record.run_id.clone(), - start_action_id: record.start_action_id.clone(), - start_action_fingerprint: record.start_action_fingerprint.clone(), - }; - let terminal = terminate_process_session_at(root, &identity, &record.process_id, None)?; - if terminal.status == "running" || terminal.needs_reconciliation { - return Err(format!( - "进程会话 {} 尚未形成可信终态(status={},needsReconciliation={}),不能把 run 标记为已取消", - record.process_id, terminal.status, terminal.needs_reconciliation - )); - } - } - if active_process_session_records_at(root, Some(agent_id), Some(run_id))?.is_empty() { - Ok(()) - } else { - Err("仍有未收束的 process session,不能把 run 标记为已取消".to_string()) - } -} - pub(crate) fn shutdown_all_process_sessions() { #[cfg(target_os = "linux")] { @@ -166,6 +33,8 @@ pub(crate) fn shutdown_all_process_sessions() { } } +/// Runner 退出等待只由生产接入(runner/server/desktop.rs)调用。 +#[cfg(not(test))] pub(crate) fn shutdown_all_process_sessions_and_wait(timeout: Duration) -> Result<(), String> { shutdown_all_process_sessions(); let deadline = std::time::Instant::now() + timeout; @@ -207,15 +76,3 @@ pub(crate) fn shutdown_all_process_sessions_and_wait(timeout: Duration) -> Resul thread::sleep(Duration::from_millis(25)); } } - -#[cfg(test)] -pub(crate) fn clear_process_session_registry_for_tests() { - shutdown_all_process_sessions(); - if let Ok(mut registry) = process_session_registry().lock() { - registry.sessions.clear(); - } - #[cfg(target_os = "linux")] - if let Ok(mut registry) = pending_process_launch_registry().lock() { - registry.launches.clear(); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 9c47ae81c..52bfbbb17 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -1,1823 +1,4 @@ use super::*; -#[cfg(target_os = "linux")] -use std::process::Stdio; - -#[cfg(target_os = "linux")] -mod owner_fixture_cleanup; -#[cfg(target_os = "linux")] -use owner_fixture_cleanup::{project_processes, OwnerFixtureCleanup}; - -static PROCESS_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); - -fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> { - PROCESS_SESSION_TEST_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -fn process_identity(project_id: &str) -> ProcessSessionIdentity { - ProcessSessionIdentity { - project_id: project_id.to_string(), - agent_id: "code-prototype".to_string(), - task_id: "code-prototype".to_string(), - conversation_session_id: "session-process-test".to_string(), - run_id: "run-process-test".to_string(), - start_action_id: "action-process-start-test".to_string(), - start_action_fingerprint: "a".repeat(64), - } -} - -fn process_test_command_spec(root: &Path) -> ProjectCommandSpec { - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write process test package.json"); - resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve process test command") -} - -#[test] -fn process_session_cursor_preserves_unicode_boundaries() { - let process_id = "proc-0123456789abcdef0123456789abcdef"; - let state = ProcessOutputState { - text: "甲乙abc".to_string(), - status: "running".to_string(), - exit_code: None, - signal: None, - stdin_open: true, - reader_finished: false, - output_limit_exceeded: false, - source_fingerprint_after: None, - source_changed: None, - needs_reconciliation: false, - launch_failure_kind: None, - }; - let first = poll_result_from_output( - process_id, - &state.text, - &state, - "test", - "test", - "test", - "test-v1", - "established", - "established", - None, - None, - 2, - ) - .expect("first unicode page"); - assert_eq!(first.output, "甲乙"); - assert!(first.has_more); - let second = poll_result_from_output( - process_id, - &state.text, - &state, - "test", - "test", - "test", - "test-v1", - "established", - "established", - None, - Some(&first.next_cursor), - 3, - ) - .expect("second unicode page"); - assert_eq!(second.output, "abc"); - assert!(!second.has_more); -} - -fn write_legacy_process_session_record( - root: &Path, - record: &ProcessSessionRecord, - schema_version: &str, -) { - let mut value = serde_json::to_value(record).expect("serialize legacy record"); - let object = value.as_object_mut().expect("legacy record object"); - object.insert( - "schemaVersion".to_string(), - serde_json::Value::String(schema_version.to_string()), - ); - for field in [ - "sandboxEstablishment", - "targetExec", - "launchFailureKind", - "sandboxReadyAt", - "execEstablishedAt", - ] { - object.remove(field); - } - if schema_version == "1" { - for field in [ - "sandboxBackend", - "sandboxMode", - "networkAccess", - "sandboxProfileVersion", - ] { - object.remove(field); - } - } - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &process_session_record_relative_path(&record.process_id), - "legacy process session", - &value, - PROCESS_SESSION_RECORD_MAX_BYTES, - ) - .expect("write legacy process record"); -} - -#[test] -fn process_session_v1_v2_active_records_migrate_to_v3_reconciliation() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "legacy-active-project", "Legacy Active Project") - .expect("initialize project"); - for (index, schema_version) in ["1", "2"].into_iter().enumerate() { - let mut identity = process_identity("legacy-active-project"); - identity.start_action_id = format!("legacy-active-action-{index}"); - identity.start_action_fingerprint = format!("{}", index + 1).repeat(64); - let process_id = format!("proc-{:032x}", index + 1); - let record = initial_process_session_record( - &identity, - &process_id, - &format!("cmd-legacy-active-{index}"), - &process_test_command_spec(root), - None, - &"a".repeat(64), - "running", - ); - write_legacy_process_session_record(root, &record, schema_version); - - let migrated = read_process_session_record(root, &process_id) - .expect("read migrated active record") - .expect("active record exists"); - assert_eq!(migrated.schema_version, "3"); - assert_eq!(migrated.status, "needs-reconciliation"); - assert_eq!(migrated.sandbox_establishment, "unknown"); - assert_eq!(migrated.target_exec, "unknown"); - assert_eq!( - migrated.launch_failure_kind.as_deref(), - Some("legacy-active-record") - ); - assert!(migrated.needs_reconciliation); - assert!(migrated.terminal_at.is_some()); - let repeated = read_process_session_record(root, &process_id) - .expect("read migrated active record again") - .expect("active record remains"); - assert_eq!(repeated, migrated); - } - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_v1_v2_terminal_records_remain_readable_without_reconciliation() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "legacy-terminal-project", "Legacy Terminal Project") - .expect("initialize project"); - for (index, schema_version) in ["1", "2"].into_iter().enumerate() { - let mut identity = process_identity("legacy-terminal-project"); - identity.start_action_id = format!("legacy-terminal-action-{index}"); - identity.start_action_fingerprint = format!("{}", index + 3).repeat(64); - let process_id = format!("proc-{:032x}", index + 16); - let mut record = initial_process_session_record( - &identity, - &process_id, - &format!("cmd-legacy-terminal-{index}"), - &process_test_command_spec(root), - None, - &"b".repeat(64), - "exited", - ); - record.exit_code = Some(index as i32); - record.terminal_at = Some(record.started_at); - let output = format!("LEGACY-TERMINAL-{schema_version}"); - record.output_bytes = output.len(); - record.output_sha256 = format!("{:x}", Sha256::digest(output.as_bytes())); - record.output_ref = Some(process_session_transcript_relative_path(&process_id)); - let transcript = ProcessSessionTranscript { - schema_version: schema_version.to_string(), - project_id: identity.project_id.clone(), - agent_id: identity.agent_id.clone(), - task_id: identity.task_id.clone(), - conversation_session_id: identity.conversation_session_id.clone(), - run_id: identity.run_id.clone(), - start_action_id: identity.start_action_id.clone(), - start_action_fingerprint: identity.start_action_fingerprint.clone(), - process_id: process_id.clone(), - output: output.clone(), - output_sha256: record.output_sha256.clone(), - output_bytes: output.len(), - updated_at: record.updated_at, - }; - write_agent_runtime_json_sidecar_with_max_bytes( - root, - record.output_ref.as_deref().expect("legacy output ref"), - "legacy process transcript", - &transcript, - PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, - ) - .expect("write legacy process transcript"); - write_legacy_process_session_record(root, &record, schema_version); - - let migrated = read_process_session_record(root, &process_id) - .expect("read migrated terminal record") - .expect("terminal record exists"); - assert_eq!(migrated.schema_version, "3"); - assert_eq!(migrated.status, "exited"); - assert_eq!(migrated.sandbox_establishment, "unknown"); - assert_eq!(migrated.target_exec, "unknown"); - assert_eq!( - migrated.launch_failure_kind.as_deref(), - Some("legacy-record") - ); - assert!(!migrated.needs_reconciliation); - let poll = - poll_process_session_at(root, &identity, &process_id, None, Some(8_000), Some(0)) - .expect("poll migrated terminal record"); - assert_eq!(poll.status, "exited"); - assert_eq!(poll.exit_code, Some(index as i32)); - assert_eq!(poll.output, output); - } - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_v3_rejects_untrusted_state_combinations_and_timestamps() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "v3-validation-project", "V3 Validation Project") - .expect("initialize project"); - let identity = process_identity("v3-validation-project"); - let process_id = "proc-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - let spec = process_test_command_spec(root); - let mut record = initial_process_session_record( - &identity, - process_id, - "cmd-v3-validation", - &spec, - None, - &"c".repeat(64), - "running", - ); - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.sandbox_establishment = "established".to_string(); - record.target_exec = "established".to_string(); - record.sandbox_ready_at = Some(record.started_at); - record.exec_established_at = Some(record.started_at); - record.launch_failure_kind = Some("arbitrary".to_string()); - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.launch_failure_kind = None; - record.sandbox_ready_at = Some(record.started_at.saturating_add(2)); - record.exec_established_at = Some(record.started_at.saturating_add(1)); - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.status = "failed".to_string(); - record.sandbox_establishment = "unknown".to_string(); - record.target_exec = "unknown".to_string(); - record.launch_failure_kind = Some("launch-unknown".to_string()); - record.sandbox_ready_at = None; - record.exec_established_at = None; - record.terminal_at = Some(record.started_at); - record.needs_reconciliation = false; - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.status = "needs-reconciliation".to_string(); - record.needs_reconciliation = true; - assert!(validate_process_session_record(root, &record, process_id).is_ok()); - record.needs_reconciliation = false; - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.needs_reconciliation = true; - record.sandbox_establishment = "established".to_string(); - record.target_exec = "established".to_string(); - record.launch_failure_kind = Some("start-audit-failed".to_string()); - record.sandbox_ready_at = Some(record.started_at); - record.exec_established_at = Some(record.started_at); - assert!(validate_process_session_record(root, &record, process_id).is_ok()); - record.status = "failed".to_string(); - assert!(validate_process_session_record(root, &record, process_id).is_err()); - - record.status = "launching".to_string(); - record.needs_reconciliation = false; - record.target_exec = "not-attempted".to_string(); - record.launch_failure_kind = None; - record.exec_established_at = None; - record.terminal_at = None; - assert!(validate_process_session_record(root, &record, process_id).is_ok()); - record.sandbox_ready_at = None; - assert!(validate_process_session_record(root, &record, process_id).is_err()); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn pending_process_launch_blocks_idle_until_guard_is_dropped() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "pending-launch-project", "Pending Launch Project") - .expect("initialize project"); - let pending = reserve_pending_process_launch( - root, - "code-prototype", - "proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ) - .expect("register pending launch"); - assert!(has_active_process_sessions_at(root).expect("pending launch is active")); - shutdown_all_process_sessions(); - assert!( - activate_pending_process_launch("proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", i32::MAX,) - .expect_err("shutdown cancels a launch before pid activation") - .contains("shutdown") - ); - drop(pending); - assert!(!has_active_process_sessions_at(root).expect("pending launch removed")); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_ansi_stripper_handles_split_csi_and_osc() { - let mut stripper = AnsiStripper::default(); - let mut visible = Vec::new(); - for chunk in [ - b"A\x1b[3".as_slice(), - b"1mB\x1b]52;c;secret".as_slice(), - b"\x07C\x1b[0m\n".as_slice(), - ] { - for byte in chunk { - stripper.push(*byte, &mut visible); - } - } - assert_eq!(String::from_utf8(visible).expect("utf8"), "ABC\n"); -} - -#[cfg(windows)] -#[test] -fn process_session_terminal_reposition_detector_ignores_color_sequences() { - let mut detector = AnsiTerminalRepositionDetector::default(); - let color = b"\x1b[31m"; - assert!(!color.iter().any(|byte| detector.push(*byte))); - - let mut reposition = AnsiTerminalRepositionDetector::default(); - let sequence = b"\x1b[5;1H"; - assert_eq!( - sequence - .iter() - .filter(|byte| reposition.push(**byte)) - .count(), - 1 - ); -} - -#[cfg(windows)] -#[test] -fn process_session_windows_npm_builder_uses_node_cli_and_native_script_shell() { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "npm-builder-project", "Npm Builder Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare npm launch"); - let command = process_session_command_builder(&launch).expect("build npm PTY command"); - let argv = command.get_argv(); - - assert!(argv[0].to_string_lossy().ends_with("node.exe")); - assert!(argv[1] - .to_string_lossy() - .replace('\\', "/") - .ends_with("node_modules/npm/bin/npm-cli.js")); - assert!(argv - .iter() - .all(|argument| !argument.to_string_lossy().starts_with(r"\\?\"))); - assert_eq!( - command.get_env("npm_config_script_shell"), - Some(std::ffi::OsStr::new(r"C:\Windows\System32\cmd.exe")) - ); - assert_eq!( - command.get_env("NODE"), - command.get_env("npm_node_execpath") - ); - assert!(command - .get_cwd() - .is_some_and(|cwd| !cwd.to_string_lossy().starts_with(r"\\?\"))); -} - -#[test] -fn process_session_real_pty_streams_stdin_and_terminates() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-project", "Process Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("fixture.js"), - r#" -process.stdin.setEncoding('utf8'); -console.log('\u001b[31mREADY\u001b[0m'); -console.log(`BRIDGE_ENV:${Object.keys(globalThis['process']['env']).filter((name) => name.includes('PROCESS_SESSION_BRIDGE')).join(',')}`); -console.log(`TARGET_ARGV:${process.argv.join('|')}`); -process.stdin.on('data', (chunk) => console.log(`ECHO:${chunk.trim()}`)); -process.on('SIGTERM', () => { console.log('STOPPED'); process.exit(0); }); -setInterval(() => {}, 1000); -"#, - ) - .expect("write fixture"); - - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let identity = process_identity("process-project"); - let source_fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); - let mut poll = start_process_session_at(root, identity.clone(), &spec, source_fingerprint) - .expect("start process session"); - assert!(poll.output.is_empty()); - assert_eq!(poll.cursor, poll.next_cursor); - #[cfg(target_os = "linux")] - { - assert_eq!(poll.sandbox_backend, "bubblewrap"); - assert_eq!(poll.sandbox_mode, "workspace-write"); - assert_eq!(poll.network_access, "disabled"); - assert_eq!(poll.sandbox_profile_version, "workspace-v1"); - assert_eq!(poll.sandbox_establishment, "established"); - assert_eq!(poll.target_exec, "established"); - assert_eq!(poll.launch_failure_kind, None); - } - assert!(has_active_process_sessions_at(root).expect("active process probe")); - let mut combined = poll.output.clone(); - for _ in 0..20 { - if combined.contains("READY") { - break; - } - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(500), - ) - .expect("poll ready"); - combined.push_str(&poll.output); - } - assert!(combined.contains("READY"), "output: {combined}"); - assert!(!combined.contains("[31m"), "output: {combined}"); - - let mut foreign_identity = identity.clone(); - foreign_identity.agent_id = "art-director".to_string(); - assert!(poll_process_session_at( - root, - &foreign_identity, - &poll.process_id, - None, - Some(10), - Some(0), - ) - .is_err()); - assert!(write_process_session_stdin_at( - root, - &foreign_identity, - &poll.process_id, - "blocked", - true, - false, - ) - .is_err()); - - let stdin = - write_process_session_stdin_at(root, &identity, &poll.process_id, "你好", true, false) - .expect("write stdin"); - assert_eq!(stdin.bytes_written, "你好\n".len()); - let mut echo = String::new(); - for _ in 0..20 { - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(500), - ) - .expect("poll echo"); - echo.push_str(&poll.output); - if echo.contains("ECHO:你好") { - break; - } - } - assert!(echo.contains("ECHO:你好"), "output: {echo}"); - - let terminal = - terminate_process_session_at(root, &identity, &poll.process_id, Some(&poll.next_cursor)) - .expect("terminate process session"); - assert_ne!(terminal.status, "running"); - let record = read_process_session_record(root, &poll.process_id) - .expect("read record") - .expect("record exists"); - assert_eq!(record.status, "terminated"); - assert!(record.output_ref.is_some()); - #[cfg(target_os = "linux")] - { - assert_eq!(record.sandbox_backend, "bubblewrap"); - assert_eq!(record.sandbox_mode, "workspace-write"); - assert_eq!(record.network_access, "disabled"); - assert_eq!(record.sandbox_profile_version, "workspace-v1"); - } - let transcript = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - record.output_ref.as_deref().expect("transcript ref"), - "Agent Runtime process transcript", - PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, - ) - .expect("read transcript") - .expect("transcript exists"); - let transcript_lines = transcript.output.lines().collect::>(); - assert!( - transcript_lines.contains(&"READY"), - "{:?}", - transcript.output - ); - assert!( - transcript_lines.contains(&"ECHO:你好"), - "{:?}", - transcript.output - ); - #[cfg(unix)] - assert!( - transcript_lines.contains(&"STOPPED"), - "{:?}", - transcript.output - ); - assert!( - transcript_lines - .iter() - .any(|line| line.ends_with("BRIDGE_ENV:")), - "{:?}", - transcript.output - ); - let record_json = - fs::read_to_string(root.join(process_session_record_relative_path(&record.process_id))) - .expect("read process record json"); - for private_marker in [ - "GENARRATIVE_PROCESS_SESSION_BRIDGE_ENDPOINT", - "GENARRATIVE_PROCESS_SESSION_BRIDGE_NONCE", - "genarrative-ps-", - "sandbox_ready", - "commit_exec", - "exec_established", - ] { - assert!( - !transcript.output.contains(private_marker), - "private marker leaked: {private_marker}: {:?}", - transcript.output - ); - assert!( - !record_json.contains(private_marker), - "private marker leaked to record: {private_marker}: {record_json}" - ); - } - assert!(!has_active_process_sessions_at(root).expect("terminal process probe")); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project") - .expect("initialize project"); - // leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline - // 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出, - // trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度 - // 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。 - // 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。 - // 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s), - // 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 + - // 延迟内容」两步,或让宽限可注入,而不是放宽断言。 - let spec = resolve_project_command_spec_at( - root, - "bash", - &[ - "-lc".to_string(), - "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(), - ], - ".", - 30, - ) - .expect("resolve graceful command"); - let identity = process_identity("graceful-process-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let mut poll = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - for _ in 0..20 { - if poll.output.contains("READY") { - break; - } - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(250), - ) - .expect("poll graceful ready"); - } - assert!(poll.output.contains("READY")); - - let terminated = - terminate_process_session_at(root, &identity, &poll.process_id, Some(&poll.next_cursor)) - .expect("graceful terminate"); - assert_eq!(terminated.status, "terminated"); - assert!(!terminated.needs_reconciliation); - assert_eq!( - fs::read_to_string(root.join("graceful-marker.txt")) - .expect("target completed delayed SIGTERM cleanup"), - "done" - ); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_live_registry_blocks_when_durable_record_is_missing() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "live-record-project", "Live Record Project") - .expect("initialize project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &[ - "-lc".to_string(), - "printf 'READY\\n'; while :; do sleep 1; done".to_string(), - ], - ".", - 30, - ) - .expect("resolve live record command"); - let identity = process_identity("live-record-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let started = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - fs::remove_file(root.join(process_session_record_relative_path(&started.process_id))) - .expect("remove durable process record"); - - let active = active_process_session_records_at( - root, - Some(identity.agent_id.as_str()), - Some(identity.run_id.as_str()), - ) - .expect("live registry remains authoritative blocker"); - assert!(active - .iter() - .any(|record| record.process_id == started.process_id)); - assert!(has_active_process_sessions_at(root).expect("live registry blocks idle")); - - terminate_process_session_at(root, &identity, &started.process_id, None) - .expect("terminate live record fixture"); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_fast_exit_zero_and_seven_keep_same_process_id() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - for exit_code in [0, 7] { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - let project_id = format!("fast-exit-{exit_code}-project"); - init_local_game_project_at(root, &project_id, "Fast Exit Project") - .expect("initialize project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &[ - "-lc".to_string(), - format!("printf 'FAST-{exit_code}\\n'; exit {exit_code}"), - ], - ".", - 30, - ) - .expect("resolve fast exit command"); - let mut identity = process_identity(&project_id); - identity.start_action_id = format!("fast-exit-action-{exit_code}"); - identity.start_action_fingerprint = format!("{}", exit_code + 1).repeat(64); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let started = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - let process_id = started.process_id.clone(); - assert!(started.output.is_empty()); - assert_eq!(started.cursor, started.next_cursor); - assert_eq!(started.sandbox_establishment, "established"); - assert_eq!(started.target_exec, "established"); - - let mut poll = started; - let mut output = String::new(); - for _ in 0..30 { - poll = poll_process_session_at( - root, - &identity, - &process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(250), - ) - .expect("poll fast exit"); - assert_eq!(poll.process_id, process_id); - output.push_str(&poll.output); - if poll.status != "running" && !poll.has_more { - break; - } - } - assert_eq!(poll.status, "exited", "{output}"); - assert_eq!(poll.exit_code, Some(exit_code), "{output}"); - assert!(output.contains(&format!("FAST-{exit_code}")), "{output}"); - let record = read_process_session_record(root, &process_id) - .expect("read fast exit record") - .expect("fast exit record exists"); - assert_eq!(record.process_id, process_id); - assert_eq!(record.target_exec, "established"); - assert!(record.exec_established_at.is_some()); - clear_process_session_registry_for_tests(); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_durable_commit_failure_runs_no_target_and_writes_no_record() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "durable-failure-project", "Durable Failure Project") - .expect("initialize project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &[ - "-lc".to_string(), - "printf ran > durable-target-ran.txt".to_string(), - ], - ".", - 30, - ) - .expect("resolve durable failure command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); - let identity = process_identity("durable-failure-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let error = start_prepared_process_session_at( - root, - identity.clone(), - &spec, - &launch, - fingerprint, - || { - assert!(!root.join("durable-target-ran.txt").exists()); - assert!(find_existing_start_action_record(root, &identity) - .expect("record absent inside durable callback") - .is_none()); - Err("forced durable commit failure".to_string()) - }, - ) - .expect_err("durable commit must fail"); - assert_eq!(error.stage(), ProjectCommandErrorStage::DurableCommit); - assert!(!root.join("durable-target-ran.txt").exists()); - assert!(find_existing_start_action_record(root, &identity) - .expect("search process record") - .is_none()); - assert!(!has_active_process_sessions_at(root).expect("no pending launch")); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_slow_durable_commit_keeps_target_blocked_until_commit() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "slow-commit-project", "Slow Commit Project") - .expect("initialize project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &[ - "-lc".to_string(), - "printf committed > slow-commit-target.txt".to_string(), - ], - ".", - 30, - ) - .expect("resolve slow commit command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); - let identity = process_identity("slow-commit-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let started_at = std::time::Instant::now(); - let result = start_prepared_process_session_at( - root, - identity.clone(), - &spec, - &launch, - fingerprint, - || { - thread::sleep(Duration::from_millis(3_200)); - assert!(!root.join("slow-commit-target.txt").exists()); - Ok(()) - }, - ) - .expect("slow durable commit must not time out in child"); - assert!(started_at.elapsed() >= Duration::from_millis(3_200)); - assert_eq!(result.sandbox_establishment, "established"); - assert_eq!(result.target_exec, "established"); - - let mut poll = result; - for _ in 0..20 { - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(250), - ) - .expect("poll slow commit target"); - if poll.status != "running" { - break; - } - } - assert_eq!(poll.status, "exited"); - assert_eq!( - fs::read_to_string(root.join("slow-commit-target.txt")).expect("read slow commit marker"), - "committed" - ); - clear_process_session_registry_for_tests(); -} - -#[cfg(windows)] -#[test] -fn process_session_windows_replay_runs_durable_commit_only_once() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "windows-replay-project", "Windows Replay Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); - let identity = process_identity("windows-replay-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let commit_count = std::sync::atomic::AtomicUsize::new(0); - let first = start_prepared_process_session_at( - root, - identity.clone(), - &spec, - &launch, - fingerprint.clone(), - || { - commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }, - ) - .expect("first start"); - let second = start_prepared_process_session_at( - root, - identity.clone(), - &spec, - &launch, - fingerprint, - || { - commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }, - ) - .expect("idempotent replay"); - assert_eq!(first.process_id, second.process_id); - assert_eq!(commit_count.load(std::sync::atomic::Ordering::SeqCst), 1); - let record = read_process_session_record(root, &first.process_id) - .expect("read Windows replay record") - .expect("Windows replay record exists"); - assert_eq!(record.sandbox_ready_at, Some(record.started_at)); - assert_eq!(record.exec_established_at, Some(record.started_at)); - terminate_process_session_at(root, &identity, &first.process_id, None) - .expect("terminate replay fixture"); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_target_exec_failure_is_known_terminal_record() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "target-exec-failure-project", "Target Exec Failure") - .expect("initialize project"); - let mut spec = resolve_project_command_spec_at( - root, - "bash", - &["-lc".to_string(), "exit 0".to_string()], - ".", - 30, - ) - .expect("resolve command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); - spec.executable = PathBuf::from("/definitely-missing-genarrative-target"); - let identity = process_identity("target-exec-failure-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let committed = std::sync::atomic::AtomicBool::new(false); - let result = - start_prepared_process_session_at(root, identity, &spec, &launch, fingerprint, || { - committed.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }) - .expect("target exec failure is a known result"); - assert!(committed.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!(result.status, "failed"); - assert_eq!(result.sandbox_establishment, "established"); - assert_eq!(result.target_exec, "failed"); - assert_eq!( - result.launch_failure_kind.as_deref(), - Some("target-exec-failed") - ); - assert!(!result.needs_reconciliation); - assert_eq!(result.cursor, result.next_cursor); - assert!(!has_active_process_sessions_at(root).expect("known target failure is terminal")); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_start_audit_failure_terminates_and_persists_reconciliation() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "start-audit-project", "Start Audit Project") - .expect("initialize project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &["-lc".to_string(), "cat >/dev/null".to_string()], - ".", - 30, - ) - .expect("resolve command"); - let identity = process_identity("start-audit-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let started = start_process_session_at(root, identity, &spec, fingerprint).expect("start"); - mark_process_session_start_audit_failure_at( - root, - &started.process_id, - "forced agent db failure", - ) - .expect("mark start audit reconciliation"); - - let mut record = None; - for _ in 0..30 { - let current = read_process_session_record(root, &started.process_id) - .expect("read audit failure record") - .expect("audit failure record exists"); - if current.status == "needs-reconciliation" - && current.launch_failure_kind.as_deref() == Some("start-audit-failed") - { - record = Some(current); - break; - } - thread::sleep(Duration::from_millis(50)); - } - let record = record.expect("audit failure reconciliation persisted"); - assert!(record.needs_reconciliation); - assert!(!record.stdin_open); - assert_eq!(record.target_exec, "established"); - assert!(active_process_session_records_at(root, None, None) - .expect("audit failure blocks completion") - .iter() - .any(|value| value.process_id == started.process_id)); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_descendants_inherit_workspace_sandbox() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path().join("workspace"); - let outside = directory.path().join("outside-secret.txt"); - init_local_game_project_at(&root, "process-sandbox-project", "Process Sandbox Project") - .expect("initialize project"); - fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret"); - fs::create_dir_all(root.join(".git")).expect("create git control directory"); - fs::write(root.join(".git/marker"), "git").expect("write git marker"); - let outside_literal = outside.to_string_lossy().replace('"', "\\\""); - let script = format!( - r#"set -u -if cat "{outside_literal}" >/dev/null 2>&1; then echo OUTSIDE_VISIBLE; else echo OUTSIDE_BLOCKED; fi -if (printf no > .git/blocked-write) 2>/dev/null; then echo GIT_WRITABLE; else echo GIT_BLOCKED; fi -if cat .agent/manifest.json >/dev/null 2>&1; then echo AGENT_VISIBLE; else echo AGENT_HIDDEN; fi -/usr/bin/setsid /bin/bash -lc 'cd /tmp; if test -e "{outside_literal}"; then echo DESCENDANT_VISIBLE; else echo DESCENDANT_BLOCKED; fi' -/usr/bin/python3 - <<'PY' -import socket -s = socket.socket() -s.settimeout(0.2) -try: - s.connect(("1.1.1.1", 53)) - print("NETWORK_VISIBLE") -except OSError: - print("NETWORK_BLOCKED") -finally: - s.close() -PY -"# - ); - fs::write(root.join("sandbox-probe.sh"), script).expect("write sandbox probe"); - let spec = - resolve_project_command_spec_at(&root, "bash", &["sandbox-probe.sh".to_string()], ".", 30) - .expect("resolve sandbox probe"); - let identity = process_identity("process-sandbox-project"); - let fingerprint = project_command_source_fingerprint(&root).expect("source fingerprint"); - let mut poll = start_process_session_at(&root, identity.clone(), &spec, fingerprint) - .expect("start sandbox probe"); - let mut output = poll.output.clone(); - for _ in 0..30 { - if poll.status != "running" && !poll.has_more { - break; - } - poll = poll_process_session_at( - &root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(250), - ) - .expect("poll sandbox probe"); - output.push_str(&poll.output); - } - assert_eq!(poll.status, "exited", "{output}"); - for marker in [ - "OUTSIDE_BLOCKED", - "GIT_BLOCKED", - "AGENT_HIDDEN", - "DESCENDANT_BLOCKED", - "NETWORK_BLOCKED", - ] { - assert!(output.contains(marker), "missing {marker}: {output}"); - } - for marker in [ - "OUTSIDE_VISIBLE", - "GIT_WRITABLE", - "AGENT_VISIBLE", - "DESCENDANT_VISIBLE", - "NETWORK_VISIBLE", - ] { - assert!(!output.contains(marker), "unexpected {marker}: {output}"); - } - assert_eq!(poll.sandbox_backend, "bubblewrap"); - assert_eq!(poll.sandbox_mode, "workspace-write"); - assert_eq!(poll.network_access, "disabled"); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_runner_shutdown_reaps_active_session() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-shutdown-project", "Process Shutdown Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("fixture.js"), - r#" -console.log('READY'); -setInterval(() => {}, 1000); -"#, - ) - .expect("write fixture"); - - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let identity = process_identity("process-shutdown-project"); - let source_fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); - let started = start_process_session_at(root, identity.clone(), &spec, source_fingerprint) - .expect("start process session"); - assert!(has_active_process_sessions_at(root).expect("active process probe")); - let mut cursor = None; - let mut output = String::new(); - for _ in 0..40 { - let poll = poll_process_session_at( - root, - &identity, - &started.process_id, - cursor.as_deref(), - Some(2_000), - Some(500), - ) - .expect("observe shutdown fixture"); - output.push_str(&poll.output); - cursor = Some(poll.next_cursor); - if output.contains("READY") { - break; - } - } - assert!(output.contains("READY"), "{output}"); - - shutdown_all_process_sessions_and_wait(Duration::from_secs(3)) - .expect("shutdown active process sessions"); - let terminal = poll_process_session_at( - root, - &identity, - &started.process_id, - None, - Some(8_000), - Some(0), - ) - .expect("poll shutdown terminal state"); - assert_eq!(terminal.status, "terminated"); - assert_eq!(terminal.signal.as_deref(), Some("runner-shutdown")); - assert!(live_process_session(&started.process_id) - .expect("inspect terminal registry") - .is_none()); - assert!(!has_active_process_sessions_at(root).expect("terminal process probe")); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_terminal_reconciliation_still_blocks_completion() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-reconciliation-project", "Process Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture"); - let process_id = "proc-0123456789abcdef0123456789abcdef"; - let now = unix_timestamp(); - let mut record = ProcessSessionRecord { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), - project_id: "process-reconciliation-project".to_string(), - agent_id: "code-prototype".to_string(), - task_id: "code-prototype".to_string(), - conversation_session_id: "session-process-test".to_string(), - run_id: "run-process-test".to_string(), - start_action_id: "action-process-start-test".to_string(), - start_action_fingerprint: "a".repeat(64), - process_id: process_id.to_string(), - owner_boot_id: process_session_boot_id().to_string(), - command_id: "cmd-process-test".to_string(), - program: "npm".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: "unknown".to_string(), - target_exec: "unknown".to_string(), - launch_failure_kind: Some("launch-unknown".to_string()), - sandbox_ready_at: None, - exec_established_at: None, - status: "needs-reconciliation".to_string(), - exit_code: None, - signal: Some("output-read-failed".to_string()), - stdin_open: false, - 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: true, - started_at: now, - terminal_at: Some(now), - updated_at: now, - }; - write_process_session_record(root, &record).expect("write reconciliation record"); - - let active = - active_process_session_records_at(root, Some("code-prototype"), Some("run-process-test")) - .expect("read reconciliation blockers"); - assert_eq!(active.len(), 1); - assert_eq!(active[0].process_id, process_id); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve blocked command"); - let mut blocked_identity = process_identity("process-reconciliation-project"); - blocked_identity.run_id = "run-process-blocked-test".to_string(); - blocked_identity.start_action_id = "action-process-blocked-start".to_string(); - let blocked = validate_process_session_start_preflight_at(root, &blocked_identity, &spec) - .expect_err("reconciliation must block a new process session"); - assert!(blocked.contains(process_id)); - record.needs_reconciliation = false; - write_process_session_record(root, &record).expect("write invalid reconciliation record"); - assert!(active_process_session_records_at( - root, - Some("code-prototype"), - Some("run-process-test") - ) - .expect_err("invalid reconciliation record must fail closed") - .contains("可信 launch 状态组合无效")); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_capacity_preflight_counts_durable_records() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-capacity-project", "Process Capacity Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve command"); - for (index, process_id) in [ - "proc-11111111111111111111111111111111", - "proc-22222222222222222222222222222222", - ] - .into_iter() - .enumerate() - { - let mut identity = process_identity("process-capacity-project"); - identity.run_id = format!("run-process-capacity-{index}"); - identity.start_action_id = format!("action-process-capacity-{index}"); - identity.start_action_fingerprint = format!("{}", index + 1).repeat(64); - let mut record = initial_process_session_record( - &identity, - process_id, - &format!("cmd-process-capacity-{index}"), - &spec, - None, - &"f".repeat(64), - "running", - ); - record.sandbox_establishment = "established".to_string(); - record.target_exec = "established".to_string(); - record.sandbox_ready_at = Some(record.started_at); - record.exec_established_at = Some(record.started_at); - write_process_session_record(root, &record).expect("write durable running record"); - } - - let mut blocked_identity = process_identity("process-capacity-project"); - blocked_identity.run_id = "run-process-capacity-blocked".to_string(); - blocked_identity.start_action_id = "action-process-capacity-blocked".to_string(); - let error = validate_process_session_start_preflight_at(root, &blocked_identity, &spec) - .expect_err("durable records must count toward Agent capacity"); - assert!(error.contains("最多同时运行 2 个"), "{error}"); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_real_pty_eof_reaches_terminal() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-eof-project", "Process EOF Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("fixture.js"), - r#" -process.stdin.setEncoding('utf8'); -console.log('READY'); -process.stdin.on('end', () => { console.log('EOF'); process.exit(0); }); -process.stdin.resume(); -"#, - ) - .expect("write fixture"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let mut identity = process_identity("process-eof-project"); - identity.run_id = "run-process-eof-test".to_string(); - identity.start_action_id = "action-process-eof-start".to_string(); - identity.start_action_fingerprint = "c".repeat(64); - let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); - let mut poll = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - for _ in 0..20 { - if poll.output.contains("READY") { - break; - } - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(500), - ) - .expect("poll ready"); - } - // 自然 EOF 与本用例显式 close 存在竞态:PTY 在负载下可能先收到流 EOF, - // 子进程随即退出并把会话置为终态,此时 close 会以「已进入终态」拒绝。 - // 该终态正是用例要验证的收敛结果,因此只在会话仍未终止时要求 close 成功。 - match write_process_session_stdin_at(root, &identity, &poll.process_id, "", false, true) { - Ok(eof) => { - assert!(eof.eof); - assert!(!eof.stdin_open); - } - Err(error) => { - assert!( - error.contains("已进入终态"), - "closing stdin failed for a non-terminal session: {error}" - ); - } - } - let mut tail = String::new(); - for _ in 0..20 { - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(500), - ) - .expect("poll eof"); - tail.push_str(&poll.output); - if poll.status != "running" { - break; - } - } - assert_eq!(poll.status, "exited", "tail: {tail}"); - #[cfg(not(windows))] - assert!(tail.contains("EOF"), "tail: {tail}"); - // Closing a ConPTY input pipe closes the attached Windows console. Unlike a Unix PTY, - // Node's console stdin does not emit its stream-level `end` callback before that terminal - // close, so the portable contract here is the trusted `exited` terminal state above. - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_stdin_accepts_trusted_terminal_race_after_successful_eof() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "stdin-race-project", "Stdin Race Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("fixture.js"), - "process.stdout.write('READY\\n'); setInterval(() => {}, 1000);\n", - ) - .expect("write fixture"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve stdin race command"); - let identity = process_identity("stdin-race-project"); - let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); - let started = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - let result = write_process_session_stdin_at_with_after_write( - root, - &identity, - &started.process_id, - "", - false, - true, - |live| { - let mut output = live.output.lock().expect("lock terminal race output"); - output.status = "exited".to_string(); - output.exit_code = Some(0); - output.stdin_open = false; - }, - ) - .expect("successful EOF remains successful after trusted terminal wins race"); - assert!(result.eof); - assert!(!result.stdin_open); - let record = read_process_session_record(root, &started.process_id) - .expect("read terminal race record") - .expect("terminal race record exists"); - assert_eq!(record.status, "exited"); - assert!(!record.needs_reconciliation); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_overlong_unterminated_line_is_stopped() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "process-output-project", "Process Output Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("fixture.js"), - "process.stdout.write('x'.repeat(20000)); setInterval(() => {}, 1000);\n", - ) - .expect("write fixture"); - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve npm command"); - let mut identity = process_identity("process-output-project"); - identity.run_id = "run-process-output-test".to_string(); - identity.start_action_id = "action-process-output-start".to_string(); - identity.start_action_fingerprint = "d".repeat(64); - let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint"); - let mut poll = - start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while std::time::Instant::now() < deadline { - if poll.status != "running" { - break; - } - poll = poll_process_session_at( - root, - &identity, - &poll.process_id, - Some(&poll.next_cursor), - Some(8_000), - Some(100), - ) - .expect("poll output limit"); - } - assert_eq!(poll.status, "output-limit-exceeded"); - clear_process_session_registry_for_tests(); -} - -#[test] -fn process_session_old_boot_becomes_reconciliation_without_relaunch() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "stale-process-project", "Stale Process Project") - .expect("initialize project"); - let identity = process_identity("stale-process-project"); - let process_id = "proc-fedcba9876543210fedcba9876543210"; - let mut record = ProcessSessionRecord { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), - project_id: identity.project_id.clone(), - agent_id: identity.agent_id.clone(), - task_id: identity.task_id.clone(), - conversation_session_id: identity.conversation_session_id.clone(), - run_id: identity.run_id.clone(), - start_action_id: identity.start_action_id.clone(), - start_action_fingerprint: identity.start_action_fingerprint.clone(), - process_id: process_id.to_string(), - owner_boot_id: "old-runner-boot".to_string(), - command_id: "cmd-stale".to_string(), - program: "npm".to_string(), - cwd: ".".to_string(), - sandbox_backend: "bubblewrap".to_string(), - sandbox_mode: "workspace-write".to_string(), - network_access: "disabled".to_string(), - sandbox_profile_version: "workspace-v1".to_string(), - sandbox_establishment: "established".to_string(), - target_exec: "established".to_string(), - launch_failure_kind: None, - sandbox_ready_at: Some(unix_timestamp()), - exec_established_at: Some(unix_timestamp()), - 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: unix_timestamp(), - terminal_at: None, - updated_at: unix_timestamp(), - }; - write_process_session_record(root, &record).expect("write stale record"); - let poll = poll_process_session_at(root, &identity, process_id, None, Some(10), Some(0)) - .expect("reconcile stale record"); - assert_eq!(poll.status, "needs-reconciliation"); - assert!(poll.needs_reconciliation); - record = read_process_session_record(root, process_id) - .expect("read reconciled record") - .expect("record exists"); - assert_eq!(record.status, "needs-reconciliation"); - assert!(record.needs_reconciliation); - - let launching_process_id = "proc-abcdefabcdefabcdefabcdefabcdefab"; - let spec = resolve_project_command_spec_at( - root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 30, - ) - .expect("resolve stale launching command"); - let mut launching = initial_process_session_record( - &identity, - launching_process_id, - "cmd-stale-launching", - &spec, - None, - &"d".repeat(64), - "launching", - ); - launching.owner_boot_id = "old-launching-boot".to_string(); - launching.sandbox_establishment = "established".to_string(); - launching.sandbox_ready_at = Some(launching.started_at); - write_process_session_record(root, &launching).expect("write stale launching record"); - let launching_poll = poll_process_session_at( - root, - &identity, - launching_process_id, - None, - Some(10), - Some(0), - ) - .expect("reconcile stale launching record"); - assert_eq!(launching_poll.status, "needs-reconciliation"); - assert_eq!(launching_poll.target_exec, "unknown"); - assert_eq!( - launching_poll.launch_failure_kind.as_deref(), - Some("launch-unknown") - ); - assert!(launching_poll.needs_reconciliation); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_start_replay_reconciles_old_launching_record_once() { - let _guard = process_session_test_guard(); - clear_process_session_registry_for_tests(); - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "stale-replay-project", "Stale Replay Project") - .expect("initialize project"); - let identity = process_identity("stale-replay-project"); - let spec = resolve_project_command_spec_at( - root, - "bash", - &["-lc".to_string(), "exit 0".to_string()], - ".", - 30, - ) - .expect("resolve stale replay command"); - let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); - let process_id = process_session_id(&identity); - let mut record = initial_process_session_record( - &identity, - &process_id, - "cmd-stale-replay", - &spec, - Some(&launch), - &"e".repeat(64), - "launching", - ); - record.owner_boot_id = "old-replay-boot".to_string(); - record.sandbox_establishment = "established".to_string(); - record.sandbox_ready_at = Some(record.started_at); - write_process_session_record(root, &record).expect("write stale replay record"); - - let callback_called = std::sync::atomic::AtomicBool::new(false); - let error = - start_prepared_process_session_at(root, identity, &spec, &launch, "e".repeat(64), || { - callback_called.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }) - .expect_err("old launching replay must reconcile without relaunch"); - assert_eq!(error.stage(), ProjectCommandErrorStage::LaunchUnknown); - assert!(!callback_called.load(std::sync::atomic::Ordering::SeqCst)); - let reconciled = read_process_session_record(root, &process_id) - .expect("read replay reconciliation") - .expect("replay record exists"); - assert_eq!(reconciled.status, "needs-reconciliation"); - assert_eq!(reconciled.target_exec, "unknown"); - assert!(reconciled.exec_established_at.is_none()); - assert_eq!( - reconciled.launch_failure_kind.as_deref(), - Some("launch-unknown") - ); - assert!(reconciled.needs_reconciliation); - clear_process_session_registry_for_tests(); -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_child_wrapper_fixture() { - if std::env::var_os(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV).is_none() { - return; - } - let args = vec![PROCESS_SESSION_CHILD_MODE.to_string()]; - match run_process_session_child(&args) { - Ok(exit_code) => std::process::exit(exit_code), - Err(error) => panic!("process session child wrapper failed: {error}"), - } -} - -#[test] -fn process_session_runner_owner_fixture() { - let Some(root) = std::env::var_os("GENARRATIVE_PROCESS_SESSION_OWNER_FIXTURE_ROOT") else { - return; - }; - let root = PathBuf::from(root); - let spec = resolve_project_command_spec_at( - &root, - "npm", - &["run".to_string(), "dev".to_string()], - ".", - 300, - ) - .expect("resolve owner fixture command"); - let identity = process_identity("owner-process-project"); - let source_fingerprint = - project_command_source_fingerprint(&root).expect("owner fixture fingerprint"); - let poll = start_process_session_at(&root, identity, &spec, source_fingerprint) - .expect("start owner fixture process"); - fs::write(root.join("owner-ready"), poll.process_id).expect("write owner ready"); - loop { - thread::sleep(Duration::from_secs(1)); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn process_session_owner_sigkill_leaves_no_child_process() { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - init_local_game_project_at(root, "owner-process-project", "Owner Process Project") - .expect("initialize project"); - fs::write( - root.join("package.json"), - r#"{"scripts":{"dev":"node owner-fixture.js"}}"#, - ) - .expect("write package.json"); - fs::write( - root.join("owner-fixture.js"), - r#" -process.on('SIGHUP', () => {}); -require('fs').writeFileSync('child.pid', String(process.pid)); -setInterval(() => {}, 1000); -"#, - ) - .expect("write fixture"); - - let current_exe = std::env::current_exe().expect("current test binary"); - let mut owner = std::process::Command::new(current_exe) - .arg("--exact") - .arg("process_session::tests::process_session_runner_owner_fixture") - .arg("--nocapture") - .env("GENARRATIVE_PROCESS_SESSION_OWNER_FIXTURE_ROOT", root) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn owner fixture test process"); - let cleanup = OwnerFixtureCleanup { - owner: &mut owner, - root, - }; - let deadline = std::time::Instant::now() + Duration::from_secs(10); - while (!root.join("owner-ready").is_file() || project_processes(root).is_empty()) - && std::time::Instant::now() < deadline - { - thread::sleep(Duration::from_millis(25)); - } - let sandbox_processes = project_processes(root); - assert!( - !sandbox_processes.is_empty(), - "sandbox child should be visible from host /proc" - ); - - // Linux Child::kill 发送 SIGKILL;先检查真实子树回收,再由 guard 兜底。 - cleanup.owner.kill().expect("SIGKILL owner fixture"); - cleanup.owner.wait().expect("reap owner fixture"); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - let remaining = project_processes(root); - if remaining.is_empty() { - break; - } - assert!( - std::time::Instant::now() < deadline, - "Runner owner SIGKILL 后 sandbox 子进程仍存在:pids={remaining:?}" - ); - thread::sleep(Duration::from_millis(25)); - } -} #[cfg(windows)] #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs deleted file mode 100644 index af07502cc..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::fs; -use std::path::Path; -use std::process::Child; -use std::thread; -use std::time::{Duration, Instant}; - -pub(super) fn project_processes(root: &Path) -> Vec { - let Ok(canonical_root) = fs::canonicalize(root) else { - return Vec::new(); - }; - fs::read_dir("/proc") - .into_iter() - .flatten() - .flatten() - .filter_map(|entry| { - let process_id = entry.file_name().to_string_lossy().parse::().ok()?; - if process_id <= 1 || process_id == std::process::id() as i32 { - return None; - } - let cwd = fs::read_link(entry.path().join("cwd")).ok()?; - (cwd == canonical_root).then_some(process_id) - }) - .collect() -} - -pub(super) struct OwnerFixtureCleanup<'a> { - pub(super) owner: &'a mut Child, - pub(super) root: &'a Path, -} - -impl Drop for OwnerFixtureCleanup<'_> { - fn drop(&mut self) { - let _ = self.owner.kill(); - let _ = self.owner.wait(); - - // 正常路径先验证子进程自行退出;这里只兜底作用域退出(包括 panic)后的残留。 - // 项目目录由每条用例独占,不能按进程名清理其他用例或开发进程。 - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let remaining = project_processes(self.root); - if remaining.is_empty() { - return; - } - for process_id in &remaining { - unsafe { - libc::kill(*process_id, libc::SIGKILL); - } - } - if Instant::now() >= deadline { - // Drop 可能在 panic 展开期间执行,不能再次 panic。 - use std::io::Write; - let _ = writeln!( - std::io::stderr(), - "owner fixture cleanup timed out: pids={remaining:?}" - ); - return; - } - thread::sleep(Duration::from_millis(25)); - } - } -} - -#[test] -fn owner_fixture_cleanup_reaps_processes_on_panic_without_touching_other_projects() { - use std::panic::{catch_unwind, AssertUnwindSafe}; - use std::process::{Command, Stdio}; - - // 回归夹具自己的回收不能依赖被测 guard,否则 guard 回归时测试也会泄漏。 - struct Sleeper(Child); - impl Drop for Sleeper { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } - } - fn sleeper(root: &Path) -> Sleeper { - Sleeper( - Command::new("sleep") - .arg("60") - .current_dir(root) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn cleanup fixture"), - ) - } - - // 同时覆盖 owner 刚启动就失败,以及已有残留进程时失败。 - for has_residual in [false, true] { - let project = tempfile::tempdir().expect("cleanup project"); - let other_project = tempfile::tempdir().expect("unrelated project"); - let mut owner = sleeper(project.path()); - let cleanup = OwnerFixtureCleanup { - owner: &mut owner.0, - root: project.path(), - }; - // 故意不依赖 owner 退出监测,验证兜底能清理仍留在项目目录的进程。 - let mut residual = has_residual.then(|| sleeper(project.path())); - let mut other = sleeper(other_project.path()); - let result = catch_unwind(AssertUnwindSafe(move || { - let _cleanup = cleanup; - panic!("simulate an assertion failure before owner shutdown"); - })); - - let owner_status = owner.0.try_wait(); - let residual_status = residual.as_mut().map(|child| child.0.try_wait()); - let other_status = other.0.try_wait(); - // 即使 guard 回归,先收口本测试持有的进程再断言,避免回归用例自身泄漏。 - drop(owner); - drop(residual); - drop(other); - - assert!(result.is_err()); - assert!(matches!(owner_status, Ok(Some(_))), "owner must exit"); - if has_residual { - assert!( - matches!(residual_status, Some(Ok(Some(_)))), - "residual process must exit" - ); - } - assert!( - matches!(other_status, Ok(None)), - "other project must survive" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 85ab8fd04..f945d519e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1,10 +1,10 @@ use super::*; use sha2::{Digest, Sha256}; -use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; mod asset_export; +#[cfg(not(test))] mod asset_rename; mod bootstrap; mod checkpoint; @@ -19,12 +19,14 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +#[cfg(not(test))] mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; #[cfg(not(test))] pub(crate) use asset_export::*; +#[cfg(not(test))] pub(crate) use asset_rename::*; pub(crate) use bootstrap::*; pub(crate) use checkpoint::*; @@ -40,5 +42,6 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +#[cfg(not(test))] pub(crate) use version_resource_replacement::*; pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 16a021d28..35aea05c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -1,5 +1,4 @@ use super::*; -use std::collections::BTreeSet; #[cfg(windows)] use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; @@ -1162,6 +1161,7 @@ fn validate_agent_db_append_class_record_size( Ok(()) } +#[cfg(test)] pub(crate) fn append_agent_db_lifecycle_record_idempotent( root: &Path, identity_field: &str, @@ -1211,6 +1211,7 @@ pub(crate) fn append_agent_db_lifecycle_record_idempotent( Ok(true) } +#[cfg(test)] fn validate_agent_db_lifecycle_record_input<'a>( identity_field: &str, identity_value: &str, @@ -1611,6 +1612,7 @@ fn is_terminal_agent_db_action_status(status: &str) -> bool { ) } +#[cfg(test)] pub(crate) fn append_agent_db_record_if_missing_for_action( root: &Path, record_type: &str, @@ -1630,6 +1632,7 @@ pub(crate) fn append_agent_db_record_if_missing_for_action( ) } +#[cfg(test)] pub(crate) fn append_agent_db_record_if_missing_for_action_and_delegation_group( root: &Path, record_type: &str, @@ -1682,6 +1685,7 @@ pub(crate) fn append_agent_db_record_if_missing_for_action_and_delegation_group( Ok(true) } +#[cfg(test)] pub(crate) fn append_agent_db_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -1936,147 +1940,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( Ok(true) } -pub(crate) fn append_agent_db_agent_message_if_missing( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, - record: serde_json::Value, -) -> Result { - const RECORD_TYPE: &str = "agent.runtime.agent.message"; - let object = record - .as_object() - .ok_or_else(|| "Agent DB 定向消息审计必须是对象".to_string())?; - let expected_fields = [ - "actionId", - "agentId", - "contentChars", - "contentSha256", - "messageId", - "path", - "recordType", - "runId", - "targetAgentId", - "targetSessionId", - ]; - let mut actual_fields = object.keys().map(String::as_str).collect::>(); - actual_fields.sort_unstable(); - if actual_fields != expected_fields - || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) - || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) - || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) - || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) - { - return Err("Agent DB 定向消息审计身份或字段无效".to_string()); - } - if !action_id.strip_prefix("action-").is_some_and(|suffix| { - suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - return Err("Agent DB 定向消息审计 actionId 无效".to_string()); - } - let message_id = record - .get("messageId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let content_sha256 = record - .get("contentSha256") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let path = record - .get("path") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if !is_valid_agent_db_prefixed_hex(message_id, "agent-message-", 64) - || !is_valid_agent_db_sha256(content_sha256) - || record - .get("contentChars") - .and_then(serde_json::Value::as_u64) - .is_none_or(|count| count == 0 || count > 1_200) - || ["targetAgentId", "targetSessionId"].iter().any(|field| { - record - .get(*field) - .and_then(serde_json::Value::as_str) - .is_none_or(|value| value.trim().is_empty() || value.chars().any(char::is_control)) - }) - || Path::new(path).is_absolute() - || !path.starts_with(".agent/conversations/agents/") - || !path.ends_with(".jsonl") - || path.split('/').any(|segment| segment == "..") - { - return Err("Agent DB 定向消息审计元数据无效".to_string()); - } - append_agent_db_record_if_missing_for_action_internal( - root, - RECORD_TYPE, - agent_id, - run_id, - action_id, - record, - || {}, - ) -} - -pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, - record: serde_json::Value, -) -> Result { - const RECORD_TYPE: &str = "agent.runtime.process_session.reconciled_after_runner_restart"; - if record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) - || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) - || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) - || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) - { - return Err("Agent DB 进程恢复记录身份不匹配".to_string()); - } - for field in [ - "taskId", - "sessionId", - "actionFingerprint", - "tool", - "executionMode", - "status", - "summary", - "processId", - "ownerBootId", - ] { - if record - .get(field) - .and_then(serde_json::Value::as_str) - .is_none_or(|value| value.trim().is_empty()) - { - return Err(format!("Agent DB 进程恢复记录缺少合法字段:{field}")); - } - } - if !action_id.strip_prefix("action-").is_some_and(|suffix| { - suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - return Err("Agent DB 进程恢复记录的 actionId 无效".to_string()); - } - let fingerprint = record["actionFingerprint"].as_str().unwrap_or_default(); - if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("Agent DB 进程恢复记录的 actionFingerprint 无效".to_string()); - } - if record["tool"] != "runtime.process_session" - || record["executionMode"] != "auto" - || record["status"] != "needs-reconciliation" - || record["needsReconciliation"] != true - { - return Err("Agent DB 进程恢复记录状态无效".to_string()); - } - append_agent_db_record_if_missing_for_action_internal( - root, - RECORD_TYPE, - agent_id, - run_id, - action_id, - record, - || {}, - ) -} - +#[cfg(test)] pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock( root: &Path, record_type: &str, @@ -2101,6 +1965,7 @@ where ) } +#[cfg(test)] pub(crate) fn append_agent_db_terminal_observation_if_missing_for_action( root: &Path, agent_id: &str, @@ -2120,6 +1985,7 @@ pub(crate) fn append_agent_db_terminal_observation_if_missing_for_action( ) } +#[cfg(test)] fn append_agent_db_record_if_missing_for_action_internal( root: &Path, record_type: &str, @@ -2173,6 +2039,7 @@ where Ok(true) } +#[cfg(test)] fn validate_agent_db_terminal_observation_input(record: &serde_json::Value) -> Result<(), String> { if record.get("recordType").and_then(serde_json::Value::as_str) != Some("agent.runtime.tool_observation") @@ -2216,6 +2083,7 @@ fn validate_agent_db_terminal_observation_input(record: &serde_json::Value) -> R Ok(()) } +#[cfg(test)] fn validate_agent_db_action_receipt_input( record_type: &str, record: &serde_json::Value, @@ -2229,6 +2097,7 @@ fn validate_agent_db_action_receipt_input( validate_agent_db_action_receipt_schema(record) } +#[cfg(test)] fn validate_agent_db_action_receipt_schema(record: &serde_json::Value) -> Result<(), String> { const LEGACY_FIELDS: &[&str] = &[ "recordType", @@ -2492,106 +2361,7 @@ pub(crate) fn read_agent_db_records_bounded( Ok((records.into_iter().collect(), truncated)) } -pub(crate) fn read_agent_db_action_receipts_by_identities( - root: &Path, - identities: &BTreeSet<(String, String, String)>, -) -> Result, String> { - if identities.is_empty() { - return Ok(BTreeMap::new()); - } - let path = root.join(".agent/agent.db"); - let Some(directory) = open_agent_db_directory(root, false)? else { - return Ok(BTreeMap::new()); - }; - let append_lock = project_append_lock_for(&path)?; - let _append_guard = append_lock.lock_process("Agent 本地索引")?; - verify_agent_db_directory_current(&directory)?; - let Some(mut storage) = open_agent_db_storage(directory, true, false)? else { - return Ok(BTreeMap::new()); - }; - verify_agent_db_storage_current(&storage)?; - repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; - verify_agent_db_storage_current(&storage)?; - let length = storage - .file - .metadata() - .map_err(|error| { - format!( - "读取 Agent 本地索引元数据失败:{}: {error}", - storage.path.display() - ) - })? - .len(); - if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { - return Err(format!( - "Agent 本地索引超过 {} 字节动作回执批量扫描上限:{}", - AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, - storage.path.display() - )); - } - storage.file.seek(SeekFrom::Start(0)).map_err(|error| { - format!( - "定位 Agent 本地索引失败:{}: {error}", - storage.path.display() - ) - })?; - let mut reader = BufReader::new(&mut storage.file); - let mut records = BTreeMap::new(); - let mut record_count = 0usize; - while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { - if !line.complete { - unreachable!("torn Agent DB tail was repaired under the append lock"); - } - if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { - continue; - } - record_count = record_count.saturating_add(1); - if record_count > AGENT_DB_MAX_SCAN_RECORDS { - return Err(format!( - "Agent 本地索引超过 {} 条动作回执批量扫描上限:{}", - AGENT_DB_MAX_SCAN_RECORDS, - storage.path.display() - )); - } - let record = - serde_json::from_slice::(&line.content).map_err(|error| { - format!( - "解析 Agent 本地索引失败:{}: {error}", - storage.path.display() - ) - })?; - if record.get("recordType").and_then(serde_json::Value::as_str) - != Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) - { - continue; - } - let Some(agent_id) = record.get("agentId").and_then(serde_json::Value::as_str) else { - continue; - }; - let Some(run_id) = record.get("runId").and_then(serde_json::Value::as_str) else { - continue; - }; - let Some(action_id) = record - .get("actionId") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - else { - continue; - }; - let identity = (agent_id.to_string(), run_id.to_string(), action_id.clone()); - if !identities.contains(&identity) { - continue; - } - validate_agent_db_action_receipt_schema(&record)?; - if records.insert(identity, record).is_some() { - return Err(format!( - "Agent 本地索引包含重复目标动作回执:actionId={action_id}" - )); - } - } - Ok(records) -} - +#[cfg(test)] fn validate_agent_db_action_delegation_group_records_unlocked( file: &mut File, path: &Path, @@ -2668,6 +2438,7 @@ fn validate_agent_db_action_delegation_group_records_unlocked( Ok(exact_matches == 1) } +#[cfg(test)] fn validate_agent_db_tool_plan_audit_records_unlocked( file: &mut File, path: &Path, @@ -2781,6 +2552,7 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( Ok(exact_matches == 1) } +#[cfg(test)] fn agent_db_tool_plan_stored_record_matches_expected_payload( stored: &serde_json::Value, expected: &serde_json::Value, @@ -2807,6 +2579,7 @@ fn agent_db_tool_plan_stored_record_matches_expected_payload( agent_db_stored_record_matches_expected_payload(&normalized, expected) } +#[cfg(test)] fn validate_agent_db_action_records_unlocked( file: &mut File, path: &Path, @@ -2893,6 +2666,7 @@ fn validate_agent_db_action_records_unlocked( Ok(found) } +#[cfg(test)] fn validate_agent_db_lifecycle_records_unlocked( file: &mut File, path: &Path, @@ -2941,17 +2715,20 @@ fn validate_agent_db_lifecycle_records_unlocked( Ok(target_exists) } +#[cfg(test)] struct AgentDbLifecycleSequence { identity_record: serde_json::Value, transition_counts: BTreeMap, transitions_in_physical_order: Vec, } +#[cfg(test)] struct AgentDbLifecycleScan { record_count: usize, sequences: BTreeMap, } +#[cfg(test)] fn scan_agent_db_lifecycle_records_unlocked( file: &mut File, path: &Path, @@ -3047,6 +2824,7 @@ fn scan_agent_db_lifecycle_records_unlocked( }) } +#[cfg(test)] fn agent_db_lifecycle_key_fields( record_type: &str, ) -> Result<(&'static str, &'static str), String> { @@ -3057,6 +2835,7 @@ fn agent_db_lifecycle_key_fields( } } +#[cfg(test)] pub(crate) fn read_agent_db_lifecycle_transitions_at( root: &Path, record_type: &str, @@ -3093,6 +2872,7 @@ pub(crate) fn read_agent_db_lifecycle_transitions_at( .unwrap_or_default()) } +#[cfg(test)] pub(crate) fn read_agent_db_incomplete_provider_request_ids_at( root: &Path, agent_id: &str, @@ -3133,6 +2913,7 @@ pub(crate) fn read_agent_db_incomplete_provider_request_ids_at( .collect()) } +#[cfg(test)] fn validate_agent_db_lifecycle_record_identity( existing: &serde_json::Value, expected: &serde_json::Value, @@ -3230,6 +3011,7 @@ fn validate_agent_db_lifecycle_transition_metadata( Ok(()) } +#[cfg(test)] fn validate_agent_db_lifecycle_existing_sequence( record_type: &str, transitions_in_physical_order: &[String], @@ -3267,6 +3049,7 @@ fn validate_agent_db_lifecycle_existing_sequence( Ok(()) } +#[cfg(test)] fn validate_agent_db_lifecycle_append_transition( record_type: &str, transition_value: &str, @@ -3315,6 +3098,7 @@ fn validate_agent_db_lifecycle_append_transition( } } +#[cfg(test)] fn validate_agent_db_action_record_identity( existing: &serde_json::Value, expected: &serde_json::Value, @@ -3353,6 +3137,7 @@ fn validate_agent_db_action_record_identity( Ok(()) } +#[cfg(test)] fn agent_db_canvas_generation_reconciliation_precedes_final( existing: &serde_json::Value, expected: &serde_json::Value, @@ -3494,6 +3279,7 @@ pub(crate) fn project_append_lock_for(path: &Path) -> Result( root: &Path, agent_id: &str, @@ -4492,67 +4278,6 @@ fn append_agent_db_classified_line_unlocked( verify_agent_db_storage_current(storage) } -#[cfg(test)] -pub(crate) fn fill_agent_db_to_ordinary_record_capacity_for_test( - root: &Path, -) -> Result { - let path = root.join(".agent/agent.db"); - let directory = open_agent_db_directory(root, true)? - .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; - let append_lock = project_append_lock_for(&path)?; - let _append_guard = append_lock.lock_process("Agent 本地索引测试容量填充")?; - verify_agent_db_directory_current(&directory)?; - let mut storage = open_agent_db_storage(directory, true, true)? - .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; - repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; - let capacity = scan_agent_db_reserved_tail_capacity_unlocked(&mut storage.file, &storage.path)?; - if capacity.record_count > AGENT_DB_MAX_ORDINARY_APPEND_RECORDS { - return Err(format!( - "Agent 本地索引已有 {} 条记录,超过测试普通容量 {}", - capacity.record_count, AGENT_DB_MAX_ORDINARY_APPEND_RECORDS - )); - } - let filler_records = AGENT_DB_MAX_ORDINARY_APPEND_RECORDS - capacity.record_count; - let filler_bytes = u64::try_from(filler_records) - .unwrap_or(u64::MAX) - .saturating_mul(3); - ensure_agent_db_append_capacity( - &storage.path, - capacity.file_length, - filler_bytes, - AGENT_DB_MAX_ORDINARY_APPEND_BYTES, - "测试普通审计追加软上限", - )?; - storage.file.seek(SeekFrom::End(0)).map_err(|error| { - format!( - "定位 Agent 本地索引测试容量尾部失败:{}: {error}", - storage.path.display() - ) - })?; - let chunk = "{}\n".repeat(8_192); - let mut remaining = filler_records; - while remaining >= 8_192 { - storage - .file - .write_all(chunk.as_bytes()) - .map_err(|error| format!("写入 Agent 本地索引测试容量失败:{error}"))?; - remaining -= 8_192; - } - if remaining > 0 { - storage - .file - .write_all("{}\n".repeat(remaining).as_bytes()) - .map_err(|error| format!("写入 Agent 本地索引测试容量失败:{error}"))?; - } - storage - .file - .flush() - .and_then(|_| storage.file.sync_data()) - .map_err(|error| format!("同步 Agent 本地索引测试容量失败:{error}"))?; - verify_agent_db_storage_current(&storage)?; - Ok(filler_records) -} - fn ensure_agent_db_append_capacity( path: &Path, current_length: u64, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs index b04384118..cc206efc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs @@ -28,26 +28,6 @@ pub(crate) struct RenameLocalProjectAssetResult { pub(crate) committed_project_revision: u64, } -/// 测试注入:`.agent/runtime/test-fail-next-asset-rename-manifest-write` 存在时,下一步 -/// manifest 写入按失败返回,用来验证"文件已改名必须改回原名"的回滚。 -/// -/// 与 `agent_db` 的 `test-fail-next-*` 同一套约定,且整段是 `#[cfg(test)]`:生产签名 -/// ([`rename_local_project_asset_at`])不接受任何故障注入参数,也没有第二个入口能把函数 -/// 推上"只回滚、绝不写 manifest"的那条路。 -#[cfg(test)] -fn take_rename_manifest_write_failure_injection(root: &Path) -> Result<(), String> { - let failure_path = root.join(".agent/runtime/test-fail-next-asset-rename-manifest-write"); - match std::fs::read_to_string(&failure_path) { - Ok(_) => { - std::fs::remove_file(&failure_path) - .map_err(|error| format!("清理素材改名 manifest 写失败注入标记失败:{error}"))?; - Err("fault-injected:rename-asset-manifest-write".to_string()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("读取素材改名 manifest 写失败注入标记失败:{error}")), - } -} - /// 校验调用方给出的新文件名,返回 trim 后的名字。 /// /// 判据(按顺序): @@ -291,13 +271,7 @@ pub(crate) fn rename_local_project_asset_at( let asset = manifest.assets[index].clone(); // 文件已改名:从这里开始的任何失败都必须把文件改回原名。 - // 注入结果必须并进 `write_error`、不能就地 `?` 返回,否则会绕过下面的回滚。 - #[cfg(test)] - let injected_write_error = take_rename_manifest_write_failure_injection(root).err(); - #[cfg(not(test))] - let injected_write_error: Option = None; - let write_error = - injected_write_error.or_else(|| write_manifest(&manifest_path, &manifest).err()); + let write_error = write_manifest(&manifest_path, &manifest).err(); if let Some(error) = write_error { return Err(rollback_asset_file_rename( ¤t_absolute, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 70b12ab48..9a190d3e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -123,46 +123,6 @@ pub(crate) fn create_local_project_checkpoint_at( }) } -const PROJECT_CONTENT_DIFF_CONTEXT_LINES: usize = 3; -const PROJECT_CONTENT_DIFF_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; - -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct LocalProjectContentDiffResult { - pub(crate) checkpoint_id: String, - pub(crate) content: String, - pub(crate) file_count: usize, - pub(crate) truncated: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum LocalProjectContentDiffStatus { - Added, - Changed, - Deleted, -} - -impl LocalProjectContentDiffStatus { - fn as_str(self) -> &'static str { - match self { - Self::Added => "added", - Self::Changed => "changed", - Self::Deleted => "deleted", - } - } -} - -struct LocalProjectContentDiffFile { - path: String, - status: LocalProjectContentDiffStatus, -} - -struct LocalProjectContentDiffSource { - bytes: Option>, - size: u64, - sha256: String, -} - pub(crate) fn open_project_snapshot_regular_file( path: &Path, label: &str, @@ -255,272 +215,6 @@ pub(crate) fn open_project_private_regular_file( open_project_snapshot_regular_file(path, label) } -fn read_local_project_content_diff_source( - path: &Path, -) -> Result { - let (mut file, metadata) = open_project_private_regular_file(path, "内容 diff 文件")?; - let mut hasher = Sha256::new(); - let mut bytes = (metadata.len() <= PROJECT_CONTENT_DIFF_MAX_FILE_BYTES) - .then(|| Vec::with_capacity(metadata.len() as usize)); - let mut size = 0_u64; - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = file - .read(&mut buffer) - .map_err(|error| format!("读取内容 diff 文件失败:{}: {error}", path.display()))?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - size = size.saturating_add(read as u64); - if let Some(content) = bytes.as_mut() { - if size <= PROJECT_CONTENT_DIFF_MAX_FILE_BYTES { - content.extend_from_slice(&buffer[..read]); - } else { - bytes = None; - } - } - } - let final_metadata = file - .metadata() - .map_err(|error| format!("复核内容 diff 文件失败:{}: {error}", path.display()))?; - if final_metadata.len() != size { - return Err(format!( - "内容 diff 读取期间文件发生漂移:{}", - path.display() - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if final_metadata.nlink() != 1 { - return Err(format!( - "内容 diff 文件不能是硬链接文件:{}", - path.display() - )); - } - } - #[cfg(windows)] - validate_windows_regular_file_handle(&file, "内容 diff 文件")?; - - Ok(LocalProjectContentDiffSource { - bytes, - size, - sha256: format!("{:x}", hasher.finalize()), - }) -} - -fn local_project_content_diff_source_at( - root: &Path, - checkpoint_id: &str, - file: &LocalProjectContentDiffFile, - checkpoint: bool, -) -> Result, String> { - if (checkpoint && file.status == LocalProjectContentDiffStatus::Added) - || (!checkpoint && file.status == LocalProjectContentDiffStatus::Deleted) - { - return Ok(None); - } - let relative_path = if checkpoint { - checkpoint_file_relative_path(checkpoint_id, &file.path) - } else { - file.path.clone() - }; - let path = resolve_local_project_path(root, &relative_path)?; - read_local_project_content_diff_source(&path).map(Some) -} - -fn render_local_project_content_diff_section( - file: &LocalProjectContentDiffFile, - checkpoint: Option<&LocalProjectContentDiffSource>, - current: Option<&LocalProjectContentDiffSource>, -) -> (String, bool) { - let checkpoint_sha256 = checkpoint - .map(|source| source.sha256.as_str()) - .unwrap_or("-"); - let current_sha256 = current.map(|source| source.sha256.as_str()).unwrap_or("-"); - let mut output = format!( - "diff --git a/{0} b/{0}\nstatus: {1}\ncheckpoint-sha256: {2}\ncurrent-sha256: {3}\n", - file.path, - file.status.as_str(), - checkpoint_sha256, - current_sha256, - ); - - if checkpoint.is_some_and(|source| source.bytes.is_none()) - || current.is_some_and(|source| source.bytes.is_none()) - { - let checkpoint_size = checkpoint - .map(|source| source.size.to_string()) - .unwrap_or_else(|| "-".to_string()); - let current_size = current - .map(|source| source.size.to_string()) - .unwrap_or_else(|| "-".to_string()); - output.push_str(&format!( - "[content omitted: per-file limit exceeded; limit={PROJECT_CONTENT_DIFF_MAX_FILE_BYTES} bytes; checkpoint={checkpoint_size} bytes; current={current_size} bytes; truncated]\n\n" - )); - return (output, true); - } - - let checkpoint_bytes = checkpoint - .and_then(|source| source.bytes.as_deref()) - .unwrap_or_default(); - let current_bytes = current - .and_then(|source| source.bytes.as_deref()) - .unwrap_or_default(); - if checkpoint_bytes.contains(&0) || current_bytes.contains(&0) { - output.push_str("[content omitted: binary data detected; truncated]\n\n"); - return (output, true); - } - let (Ok(checkpoint_text), Ok(current_text)) = ( - std::str::from_utf8(checkpoint_bytes), - std::str::from_utf8(current_bytes), - ) else { - output.push_str("[content omitted: non-UTF-8 data detected; truncated]\n\n"); - return (output, true); - }; - - let diff = TextDiff::from_lines(checkpoint_text, current_text); - let checkpoint_header = if file.status == LocalProjectContentDiffStatus::Added { - "/dev/null".to_string() - } else { - format!("a/{}", file.path) - }; - let current_header = if file.status == LocalProjectContentDiffStatus::Deleted { - "/dev/null".to_string() - } else { - format!("b/{}", file.path) - }; - let mut unified = diff.unified_diff(); - unified.context_radius(PROJECT_CONTENT_DIFF_CONTEXT_LINES); - unified.header(&checkpoint_header, ¤t_header); - output.push_str(&unified.to_string()); - if !output.ends_with('\n') { - output.push('\n'); - } - output.push('\n'); - (output, false) -} - -fn append_local_project_content_diff_truncation( - output: &mut String, - output_chars: &mut usize, - max_chars: usize, - detail: &str, -) { - let marker = format!("[content diff truncated: {detail}]\n"); - let marker_chars = marker.chars().count(); - if output_chars.saturating_add(marker_chars) <= max_chars { - output.push_str(&marker); - *output_chars += marker_chars; - } else if *output_chars < max_chars { - output.push('…'); - *output_chars += 1; - } -} - -pub(crate) fn diff_local_project_checkpoint_content_at( - root: &Path, - checkpoint_id: &str, - max_files: usize, - max_chars: usize, -) -> Result { - let initial_diff = diff_local_project_checkpoint_at(root, checkpoint_id)?; - let checkpoint_id = initial_diff.checkpoint_id.clone(); - let mut files = Vec::with_capacity( - initial_diff.added.len() + initial_diff.changed.len() + initial_diff.deleted.len(), - ); - files.extend( - initial_diff - .added - .iter() - .map(|entry| LocalProjectContentDiffFile { - path: entry.path.clone(), - status: LocalProjectContentDiffStatus::Added, - }), - ); - files.extend( - initial_diff - .changed - .iter() - .map(|entry| LocalProjectContentDiffFile { - path: entry.path.clone(), - status: LocalProjectContentDiffStatus::Changed, - }), - ); - files.extend( - initial_diff - .deleted - .iter() - .map(|entry| LocalProjectContentDiffFile { - path: entry.path.clone(), - status: LocalProjectContentDiffStatus::Deleted, - }), - ); - files.sort_by(|left, right| left.path.cmp(&right.path)); - - let mut content = String::new(); - let mut content_chars = 0_usize; - let mut file_count = 0_usize; - let mut truncated = false; - let mut character_budget_exhausted = false; - let file_limit = max_files.min(files.len()); - for (index, file) in files.iter().take(file_limit).enumerate() { - let checkpoint = local_project_content_diff_source_at(root, &checkpoint_id, file, true)?; - let current = local_project_content_diff_source_at(root, &checkpoint_id, file, false)?; - let (section, section_truncated) = - render_local_project_content_diff_section(file, checkpoint.as_ref(), current.as_ref()); - let section_chars = section.chars().count(); - let has_unprocessed_files = index + 1 < files.len(); - let truncation_reserve = usize::from(has_unprocessed_files && max_chars > 0); - if content_chars - .saturating_add(section_chars) - .saturating_add(truncation_reserve) - > max_chars - { - truncated = true; - character_budget_exhausted = true; - append_local_project_content_diff_truncation( - &mut content, - &mut content_chars, - max_chars, - &format!( - "character budget reached; {} file diff(s) omitted", - files.len() - file_count - ), - ); - break; - } - content.push_str(§ion); - content_chars += section_chars; - file_count += 1; - truncated |= section_truncated; - } - if !character_budget_exhausted && file_limit < files.len() { - truncated = true; - append_local_project_content_diff_truncation( - &mut content, - &mut content_chars, - max_chars, - &format!( - "file budget reached; {} file diff(s) omitted", - files.len() - file_count - ), - ); - } - let final_diff = diff_local_project_checkpoint_at(root, &checkpoint_id)?; - if final_diff != initial_diff { - return Err("内容 diff 读取期间项目文件发生变化,请重新执行 project.diff".to_string()); - } - - Ok(LocalProjectContentDiffResult { - checkpoint_id, - content, - file_count, - truncated, - }) -} - pub(crate) fn diff_local_project_checkpoint_at( root: &Path, checkpoint_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint/security_tests.rs index 9b3ccb76f..37a22de84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint/security_tests.rs @@ -50,130 +50,9 @@ fn write_checkpoint_file(root: &Path, checkpoint_id: &str, path: &str, content: fs::write(target, content).expect("write checkpoint file"); } -#[test] -fn checkpoint_content_diff_renders_added_changed_and_deleted_unified_hunks() { - let root = unique_checkpoint_test_root("content-diff-hunks"); - fs::create_dir_all(root.join("game")).expect("create game directory"); - fs::write( - root.join("game/changed.txt"), - "line-1\nline-2\nline-3\nline-4\nold-line\nline-6\nline-7\nline-8\n", - ) - .expect("write changed fixture"); - fs::write(root.join("game/deleted.txt"), "deleted\n").expect("write deleted fixture"); - let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint"); - - fs::write( - root.join("game/changed.txt"), - "line-1\nline-2\nline-3\nline-4\nnew-line\nline-6\nline-7\nline-8\n", - ) - .expect("update changed fixture"); - fs::remove_file(root.join("game/deleted.txt")).expect("delete fixture"); - fs::write(root.join("game/added.txt"), "added\n").expect("write added fixture"); - - let result = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 20_000) - .expect("render checkpoint content diff"); - - assert_eq!(result.checkpoint_id, checkpoint.checkpoint_id); - assert_eq!(result.file_count, 3); - assert!(!result.truncated); - assert!(result.content.contains("status: added")); - assert!(result.content.contains("status: changed")); - assert!(result.content.contains("status: deleted")); - assert!(result - .content - .contains("--- /dev/null\n+++ b/game/added.txt")); - assert!(result - .content - .contains("--- a/game/deleted.txt\n+++ /dev/null")); - assert!(result - .content - .contains(" line-2\n line-3\n line-4\n-old-line\n+new-line\n line-6\n line-7\n line-8")); - assert!(result.content.contains("checkpoint-sha256:")); - assert!(result.content.contains("current-sha256:")); - - fs::remove_dir_all(root).ok(); -} - -#[test] -fn checkpoint_content_diff_marks_file_and_character_budget_truncation() { - let root = unique_checkpoint_test_root("content-diff-budgets"); - fs::create_dir_all(root.join("game")).expect("create game directory"); - fs::write(root.join("game/a.txt"), "before a\n").expect("write a fixture"); - fs::write(root.join("game/b.txt"), "before b\n").expect("write b fixture"); - let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint"); - fs::write(root.join("game/a.txt"), "after a\n").expect("update a fixture"); - fs::write(root.join("game/b.txt"), "after b\n").expect("update b fixture"); - - let file_limited = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 1, 20_000) - .expect("render file-limited content diff"); - assert_eq!(file_limited.file_count, 1); - assert!(file_limited.truncated); - assert!(file_limited.content.contains("game/a.txt")); - assert!(!file_limited.content.contains("game/b.txt")); - assert!(file_limited.content.contains("file budget reached")); - - let character_limited = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 120) - .expect("render character-limited content diff"); - assert_eq!(character_limited.file_count, 0); - assert!(character_limited.truncated); - assert!(character_limited - .content - .contains("character budget reached")); - assert!(character_limited.content.chars().count() <= 120); - - let tiny_budget = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 1) - .expect("render tiny-budget content diff"); - assert_eq!(tiny_budget.content, "…"); - assert_eq!(tiny_budget.file_count, 0); - assert!(tiny_budget.truncated); - - fs::remove_dir_all(root).ok(); -} - -#[test] -fn checkpoint_content_diff_marks_binary_non_utf8_and_oversized_files() { - let root = unique_checkpoint_test_root("content-diff-omissions"); - fs::create_dir_all(root.join("game")).expect("create game directory"); - fs::write(root.join("game/binary.bin"), [0_u8, 1, 2]).expect("write binary fixture"); - fs::write(root.join("game/non-utf8.txt"), [0xff_u8, b'a']).expect("write non-UTF-8 fixture"); - fs::write( - root.join("game/oversized.txt"), - vec![b'a'; PROJECT_CONTENT_DIFF_MAX_FILE_BYTES as usize + 1], - ) - .expect("write oversized fixture"); - let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint"); - - fs::write(root.join("game/binary.bin"), [0_u8, 1, 3]).expect("update binary fixture"); - fs::write(root.join("game/non-utf8.txt"), [0xfe_u8, b'a']).expect("update non-UTF-8 fixture"); - fs::write( - root.join("game/oversized.txt"), - vec![b'b'; PROJECT_CONTENT_DIFF_MAX_FILE_BYTES as usize + 1], - ) - .expect("update oversized fixture"); - - let result = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 8_000) - .expect("render omitted content diff markers"); - assert_eq!(result.file_count, 3); - assert!(result.truncated); - assert!(result.content.contains("binary data detected; truncated")); - assert!(result - .content - .contains("non-UTF-8 data detected; truncated")); - assert!(result.content.contains("per-file limit exceeded")); - assert!(result.content.contains("checkpoint-sha256:")); - assert!(result.content.contains("current-sha256:")); - - fs::remove_dir_all(root).ok(); -} - #[cfg(unix)] #[test] -fn checkpoint_and_content_diff_reject_hard_linked_project_files() { +fn checkpoint_rejects_hard_linked_project_files() { let root = unique_checkpoint_test_root("hard-link-boundary"); let outside = unique_checkpoint_test_root("hard-link-outside"); fs::create_dir_all(root.join("game")).expect("create game directory"); @@ -190,23 +69,6 @@ fn checkpoint_and_content_diff_reject_hard_linked_project_files() { ); assert!(!root.join(".agent/checkpoints").exists()); - fs::remove_file(root.join("game/linked.txt")).expect("remove first hard link"); - fs::write(root.join("game/linked.txt"), "checkpoint version\n") - .expect("write safe checkpoint fixture"); - let checkpoint = create_local_project_checkpoint_at(&root).expect("create safe checkpoint"); - fs::remove_file(root.join("game/linked.txt")).expect("remove safe fixture"); - fs::hard_link(&outside_file, root.join("game/linked.txt")) - .expect("replace current file with hard link"); - - let diff_error = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 10_000) - .expect_err("content diff must reject project hard links"); - assert!( - diff_error.contains("hard link") || diff_error.contains("硬链接"), - "{diff_error}" - ); - assert!(!diff_error.contains("outside secret")); - fs::remove_dir_all(root).ok(); fs::remove_dir_all(outside).ok(); } @@ -551,18 +413,6 @@ fn snapshot_workflows_exclude_and_preserve_sensitive_paths() { ); assert!(diff.deleted.is_empty()); - let content_diff = - diff_local_project_checkpoint_content_at(&root, &checkpoint.checkpoint_id, 10, 20_000) - .expect("content diff ignores legacy sensitive checkpoint entries"); - assert_eq!(content_diff.file_count, 2); - assert!(!content_diff.truncated); - assert!(content_diff.content.contains("game/extra.txt")); - assert!(content_diff.content.contains("game/state.txt")); - assert!(!content_diff.content.contains("checkpoint secret")); - for path in sensitive_paths { - assert!(!content_diff.content.contains(path)); - } - let restored = restore_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id) .expect("restore ignores legacy sensitive checkpoint entries"); assert_eq!(restored.restored_count, 1); @@ -629,13 +479,6 @@ fn checkpoint_create_and_restore_reject_symbolic_link_boundaries() { .expect_err("checkpoint restore must reject a linked content source"); assert!(source_error.contains("符号链接"), "{source_error}"); assert!(!root.join("game/notes.txt").exists()); - let content_diff_error = - diff_local_project_checkpoint_content_at(&root, source_checkpoint_id, 10, 20_000) - .expect_err("content diff must reject a linked checkpoint source"); - assert!( - content_diff_error.contains("符号链接"), - "{content_diff_error}" - ); let target_checkpoint_id = "checkpoint-linked-target"; write_checkpoint_manifest(&root, target_checkpoint_id, &["linked/target.txt"]); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index c0c58ee12..1dfe09d94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -321,47 +321,7 @@ fn write_agent_conversation_session_catalog_unlocked( ) } -pub(crate) fn ensure_agent_conversation_session_at( - root: &Path, - agent_id: &str, - session_id: &str, - title: &str, -) -> Result<(), String> { - validate_project_root(root)?; - let agent_id = normalize_conversation_agent_id(agent_id)?; - let session_id = normalize_agent_conversation_session_id(session_id)?; - with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session 确保存在", || { - let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); - let lock = project_append_lock_for(&catalog_path)?; - let _guard = lock.lock("Agent Session 目录")?; - let mut catalog = read_agent_conversation_session_catalog_unlocked(root, &agent_id)?; - if let Some(existing) = catalog - .sessions - .iter() - .find(|session| session.session_id == session_id) - { - if existing.archived_at.is_some() { - return Err(format!("Agent Session 已归档:{session_id}")); - } - } else { - let now = unix_timestamp(); - catalog.sessions.push(AgentConversationSessionRecord { - session_id: session_id.clone(), - title: title.trim().chars().take(80).collect::(), - created_at: now, - updated_at: now, - archived_at: None, - message_count: 0, - legacy: false, - forked_from_session_id: None, - forked_message_count: None, - }); - } - catalog.active_session_id = session_id; - write_agent_conversation_session_catalog_unlocked(root, &catalog) - }) -} - +#[cfg(not(test))] fn agent_conversation_session_list_result( root: &Path, catalog: AgentConversationSessionCatalogFile, @@ -376,6 +336,7 @@ fn agent_conversation_session_list_result( } } +#[cfg(not(test))] pub(crate) fn list_game_creator_agent_sessions_at( root: &Path, agent_id: &str, @@ -388,6 +349,7 @@ pub(crate) fn list_game_creator_agent_sessions_at( .map(|catalog| agent_conversation_session_list_result(root, catalog)) } +#[cfg(not(test))] pub(crate) fn create_game_creator_agent_session_at( root: &Path, agent_id: &str, @@ -452,6 +414,7 @@ pub(crate) fn create_game_creator_agent_session_at( }) } +#[cfg(not(test))] fn new_agent_conversation_session_id( catalog: &AgentConversationSessionCatalogFile, agent_id: &str, @@ -475,6 +438,7 @@ fn new_agent_conversation_session_id( .ok_or_else(|| "无法生成唯一 Agent Session ID".to_string()) } +#[cfg(not(test))] pub(crate) fn fork_game_creator_agent_session_at( root: &Path, agent_id: &str, @@ -490,6 +454,7 @@ pub(crate) fn fork_game_creator_agent_session_at( ) } +#[cfg(not(test))] fn fork_game_creator_agent_session_with_catalog_writer_at( root: &Path, agent_id: &str, @@ -608,45 +573,7 @@ where }) } -#[cfg(test)] -pub(crate) fn fork_game_creator_agent_session_with_catalog_failure_at( - root: &Path, - agent_id: &str, - source_session_id: &str, - title: &str, -) -> Result { - fork_game_creator_agent_session_with_catalog_writer_at( - root, - agent_id, - source_session_id, - title, - |_, _| Err("测试注入 Agent Session 目录写入失败".to_string()), - ) -} - -#[cfg(test)] -pub(crate) fn fork_game_creator_agent_session_with_catalog_write_hook_at( - root: &Path, - agent_id: &str, - source_session_id: &str, - title: &str, - before_catalog_write: F, -) -> Result -where - F: FnOnce(), -{ - fork_game_creator_agent_session_with_catalog_writer_at( - root, - agent_id, - source_session_id, - title, - |root, catalog| { - before_catalog_write(); - write_agent_conversation_session_catalog_unlocked(root, catalog) - }, - ) -} - +#[cfg(not(test))] pub(crate) fn set_active_game_creator_agent_session_at( root: &Path, agent_id: &str, @@ -675,6 +602,7 @@ pub(crate) fn set_active_game_creator_agent_session_at( }) } +#[cfg(not(test))] pub(crate) fn archive_game_creator_agent_session_at( root: &Path, agent_id: &str, @@ -729,24 +657,6 @@ fn resolve_agent_conversation_session_at( .ok_or_else(|| format!("Agent Session 不存在:{session_id}")) } -pub(crate) fn resolve_agent_conversation_session_id_at( - root: &Path, - agent_id: &str, - session_id: Option<&str>, - require_writable: bool, -) -> Result { - validate_project_root(root)?; - let agent_id = normalize_conversation_agent_id(agent_id)?; - let session = resolve_agent_conversation_session_at(root, &agent_id, session_id)?; - if require_writable && session.archived_at.is_some() { - return Err(format!( - "Agent Session 已归档,只能读取:{}", - session.session_id - )); - } - Ok(session.session_id) -} - fn touch_agent_conversation_session_at( root: &Path, agent_id: &str, @@ -950,17 +860,6 @@ pub(crate) fn read_local_conversation_at( read_local_conversation_for_session_at(root, agent_id, None) } -pub(crate) fn read_local_conversation_message_by_id_for_session_at( - root: &Path, - agent_id: Option<&str>, - session_id: Option<&str>, - message_id: &str, -) -> Result, String> { - read_local_conversation_message_by_id_for_session_internal_at( - root, agent_id, session_id, message_id, true, - ) -} - pub(crate) fn read_local_conversation_message_by_id_for_session_without_touch_at( root: &Path, agent_id: Option<&str>, @@ -1236,23 +1135,7 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_at( .map(|(conversation, _)| conversation) } -pub(crate) fn append_local_conversation_message_for_session_idempotent_with_status_at( - root: &Path, - agent_id: Option<&str>, - session_id: Option<&str>, - message: LocalConversationMessage, - message_id: &str, -) -> Result<(LocalConversationResult, bool), String> { - append_local_conversation_message_for_session_internal_at( - root, - agent_id, - session_id, - message, - Some(message_id), - None, - ) -} - +#[cfg(test)] pub(crate) fn append_local_conversation_message_for_session_idempotent_with_finalization_at( root: &Path, agent_id: Option<&str>, @@ -1354,6 +1237,7 @@ pub(crate) fn append_markdown_entry( append_game_creator_private_file(path, &bytes, error_label) } +#[cfg(not(test))] pub(crate) fn append_local_permission_log_at( root: &Path, event: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index bb5b21789..052b3050a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -218,6 +218,8 @@ pub(crate) fn reject_agent_runtime_private_control_path( Ok(()) } +/// file.delete 入口退役后,只剩测试通过它验证 `.agent` 控制面删除门禁。 +#[cfg(test)] fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { if matches!( normalized_path.split('/').next(), @@ -267,6 +269,8 @@ pub(crate) fn write_local_project_file_at( }) } +/// file.delete 入口已退役:仅测试仍用它验证 `.agent`/checkpoint 控制面与硬链接门禁。 +#[cfg(test)] pub(crate) fn delete_local_project_file_at( root: &Path, relative_path: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 44e55db43..602d36e51 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -825,6 +825,7 @@ pub(crate) fn import_local_cocos_project_at( }) } +#[cfg(not(test))] pub(crate) fn import_local_unity_project_at( root: &Path, project_id: &str, @@ -980,6 +981,7 @@ pub(crate) fn game_iteration_resource_bindings( /// revision has produced a durable successful browser-playtest receipt. /// Replays are idempotent: once any formal version exists, validation never /// rewrites or appends another initial record. +#[cfg(test)] pub(crate) fn ensure_initial_game_iteration_version_at( root: &Path, project_revision: u64, @@ -1159,160 +1161,6 @@ pub(crate) fn ensure_manifest_seed_tasks(manifest: &mut GameCreationAppManifest) } } -pub(crate) fn validate_manifest_required_visual_asset( - root: &Path, - manifest: &GameCreationAppManifest, - task_id: &str, -) -> Result<(), String> { - let (expected_path, expected_kind) = match task_id { - "art-director" => ("assets/art-spec.png", GameCreationAppAssetKind::IconSpec), - "design-foundation" => ( - "assets/ui-prototype.png", - GameCreationAppAssetKind::UiDesign, - ), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - GameCreationAppAssetKind::IconSpritesheet, - ), - _ => return Ok(()), - }; - let asset = manifest - .assets - .iter() - .find(|asset| asset.local_path == expected_path && asset.kind == expected_kind) - .ok_or_else(|| format!("缺少规范视觉资产:{expected_path} ({expected_kind})"))?; - if !asset.media_type.starts_with("image/") - || asset.source.kind != GameCreationAppAssetSourceKind::Canvas - { - return Err(format!("规范视觉资产文件或来源无效:{expected_path}")); - } - let bytes = resolve_local_project_path(root, &asset.local_path) - .ok() - .and_then(|path| fs::read(path).ok()) - .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) - .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; - let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?; - if decoded.width() == 0 || decoded.height() == 0 { - return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}")); - } - if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) { - return Err("首版美术素材图没有真实透明像素".to_string()); - } - let canvas_project_id = asset - .source - .canvas_project_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("规范视觉资产缺少 canvasProjectId:{expected_path}"))?; - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("规范视觉资产缺少 resourceId:{expected_path}"))?; - - let expected_route = match task_id { - "art-asset-plan" => "/api/external/v1/editor/icon-spritesheets/generations", - _ => "/api/external/v1/editor/images/generations", - }; - let expected_generation_kind = match task_id { - "art-director" => "spec", - "design-foundation" => "ui-design", - "art-asset-plan" => "icon-spritesheet", - _ => unreachable!("non-visual tasks returned above"), - }; - if asset.source.generation_route.as_deref() != Some(expected_route) - || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) - { - return Err(format!( - "规范视觉资产缺少匹配的持久生成来源,按 legacy 资产处理:{expected_path}" - )); - } - - if task_id == "art-director" { - // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 - // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 - // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / - // resourceId / PNG 解码等身份判据全部保持不变。 - if !crate::agent::platform_art_runtime_references_match_request_contract( - &asset.source.reference_resource_ids, - expected_kind, - ) { - return Err(format!( - "统一视觉规范图的参考集合不符合请求合同:{expected_path}" - )); - } - return Ok(()); - } - - validate_manifest_required_visual_asset(root, manifest, "art-director")?; - let art_spec = manifest - .assets - .iter() - .find(|asset| { - asset.local_path == "assets/art-spec.png" - && asset.kind == GameCreationAppAssetKind::IconSpec - }) - .ok_or_else(|| "缺少当前统一视觉规范图".to_string())?; - let art_spec_project_id = art_spec - .source - .canvas_project_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "统一视觉规范图缺少 canvasProjectId".to_string())?; - let art_spec_resource_id = art_spec - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: - // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 - if !crate::agent::platform_art_runtime_references_match_request_contract( - &asset.source.reference_resource_ids, - expected_kind, - ) { - return Err(format!( - "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" - )); - } - let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); - let original_provenance_matches = - canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; - let rebound_local_source_matches = if original_provenance_matches { - true - } else { - let art_spec_bytes = resolve_local_project_path(root, &art_spec.local_path) - .ok() - .and_then(|path| fs::read(path).ok()) - .ok_or_else(|| "读取统一视觉规范图失败".to_string())?; - let source_identity = new_external_editor_source_identity( - &art_spec.id, - &format!("{:x}", Sha256::digest(&art_spec_bytes)), - &art_spec.media_type, - art_spec.kind.as_str(), - )?; - external_editor_remote_reference_matches_local_source_at( - root, - &manifest.project_id, - canvas_project_id, - reference_resource_id, - &source_identity, - )? - }; - if !rebound_local_source_matches { - return Err(format!( - "派生视觉资产未绑定当前统一视觉规范图的本地内容身份:{expected_path}" - )); - } - Ok(()) -} - pub(crate) fn set_task_status( manifest: &mut GameCreationAppManifest, task_id: &str, @@ -1682,154 +1530,6 @@ pub(crate) fn add_manifest_asset_tags_at( /// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。 pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append"; -pub(crate) fn create_manifest_task_at( - root: &Path, - task_id: &str, - title: &str, - group: GameCreationAppAgentGroup, - role: &str, - status: GameCreationAppTaskStatus, - dependencies: Vec, - artifacts: Vec, - acceptance_criteria: Vec, -) -> Result { - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; - ensure_manifest_seed_tasks(&mut manifest); - let fallback_id = format!( - "agent-task-{}-{}", - unix_timestamp(), - manifest.tasks.len() + 1 - ); - let task_id = normalize_manifest_task_id(task_id, &fallback_id)?; - if manifest.tasks.iter().any(|task| task.id == task_id) { - return Err(format!("项目任务已存在:{task_id}")); - } - let title = normalize_manifest_task_text(title, "任务标题", 120)?; - let role = normalize_manifest_task_text(role, "任务角色", 64)?; - let known_task_ids = manifest - .tasks - .iter() - .map(|task| task.id.clone()) - .collect::>(); - let dependencies = normalize_manifest_task_id_list(dependencies, "依赖任务", 8)?; - for dependency in &dependencies { - if dependency == &task_id { - return Err("任务不能依赖自己".to_string()); - } - if !known_task_ids.iter().any(|known| known == dependency) { - return Err(format!("依赖任务不存在:{dependency}")); - } - } - let artifacts = normalize_manifest_task_text_list(artifacts, "任务产物", 8, 160)?; - let acceptance_criteria = - normalize_manifest_task_text_list(acceptance_criteria, "验收标准", 8, 180)?; - let task = GameCreationAppTaskState { - id: task_id, - title, - group, - role, - status, - dependencies, - artifacts, - acceptance_criteria, - }; - manifest.tasks.push(task.clone()); - write_manifest(&manifest_path, &manifest)?; - Ok(task) -} - -fn normalize_manifest_task_id(value: &str, fallback: &str) -> Result { - let value = value.trim(); - let source = if value.is_empty() { - fallback.trim() - } else { - value - }; - let normalized = source - .to_ascii_lowercase() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' || character == '_' { - character - } else { - '-' - } - }) - .collect::(); - let normalized = normalized - .split('-') - .filter(|part| !part.is_empty()) - .collect::>() - .join("-"); - if normalized.is_empty() { - return Err("任务 ID 只能包含 ASCII 字母、数字、短横线和下划线".to_string()); - } - Ok(normalized.chars().take(96).collect()) -} - -fn normalize_manifest_task_text( - value: &str, - label: &str, - max_chars: usize, -) -> Result { - let value = value.trim(); - if value.is_empty() { - return Err(format!("{label}不能为空")); - } - if value.chars().any(char::is_control) { - return Err(format!("{label}不能包含控制字符")); - } - Ok(value.chars().take(max_chars).collect()) -} - -fn normalize_manifest_task_text_list( - values: Vec, - label: &str, - max_items: usize, - max_chars: usize, -) -> Result, String> { - let mut output = Vec::new(); - for value in values { - let value = value.trim(); - if value.is_empty() { - continue; - } - if value.chars().any(char::is_control) { - return Err(format!("{label}不能包含控制字符")); - } - let item = value.chars().take(max_chars).collect::(); - if !output.iter().any(|existing| existing == &item) { - output.push(item); - } - if output.len() > max_items { - return Err(format!("{label}最多支持 {max_items} 项")); - } - } - Ok(output) -} - -fn normalize_manifest_task_id_list( - values: Vec, - label: &str, - max_items: usize, -) -> Result, String> { - let mut output = Vec::new(); - for value in values { - let value = value.trim(); - if value.is_empty() { - continue; - } - let item = normalize_manifest_task_id(value, "")?; - if !output.iter().any(|existing| existing == &item) { - output.push(item); - } - if output.len() > max_items { - return Err(format!("{label}最多支持 {max_items} 项")); - } - } - Ok(output) -} - /// 资源标签的持久化上界:manifest 每次写入都被整份序列化重写、每次读取都被整份解析, /// 标签数量与单标签长度若无界,客户端就能让这份文件无限膨胀,并把成本摊到之后每一次读写上。 const ASSET_CLASSIFICATION_MAX_TAGS: usize = 16; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 7ec522c4f..baa9406f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -288,22 +288,6 @@ fn project_verification_package_manager_at( Ok("npm") } -#[cfg(test)] -pub(crate) fn resolve_project_verification_spec_at( - root: &Path, - script: &str, - expected_command: &str, - timeout_seconds: u64, -) -> Result { - resolve_project_verification_spec_with_cwd_at( - root, - script, - expected_command, - timeout_seconds, - ".", - ) -} - pub(crate) fn resolve_project_verification_spec_with_cwd_at( root: &Path, script: &str, @@ -673,24 +657,6 @@ where }) } -#[cfg(test)] -pub(crate) async fn run_project_verification_at( - root: &Path, - script: &str, - expected_command: &str, - timeout_seconds: u64, -) -> Result { - run_project_verification_with_commit_at( - root, - script, - expected_command, - timeout_seconds, - ".", - || Ok(()), - ) - .await -} - pub(crate) async fn run_project_verification_with_commit_at( root: &Path, script: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index bb8b80846..ffee0acc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -12,11 +12,6 @@ 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); #[test]