Merge remote-tracking branch 'origin/master' into feat/tribo3d-integeration
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m11s
Project CI / Frontend tests (pull_request) Successful in 4m15s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m33s
Project CI / Native shell tests (pull_request) Successful in 9m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m29s

# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
2026-09-22 15:35:47 +08:00
19 changed files with 1031 additions and 22 deletions
@@ -150,6 +150,7 @@ function formatDuration(milliseconds) {
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
function resolveTestExecutable() {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const cargoArguments = buildCargoArguments({
kind: options.targetKind,
@@ -194,6 +195,9 @@ function resolveTestExecutable() {
reject(new Error(`unable to start cargo: ${error.message}`));
});
child.on('close', (code) => {
console.log(
`[rust-shards] compile duration=${formatDuration(Date.now() - startedAt)} exit=${code}`,
);
if (code !== 0) {
reject(
new Error(
@@ -2,7 +2,8 @@
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar.
//! paths and credentials before writing a bounded diagnostic sidecar, and
//! projects the same bounded diagnosis into the AppData application log.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
@@ -14,6 +15,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
/// 应用日志里 detail / metadata 的字符预算。
///
/// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB
/// 这里的预算留出身份字段与中文摘要的位置,保证被截掉的是诊断正文的尾部,而不是
/// `eventId`、`code` 或 `detailRef`。
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200;
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200;
/// 详情行里 public summary 的字符预算。
///
/// `summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文;这里与 sidecar 侧的摘要
/// 预算同口径(320 字符)截断,并再脱敏一次,避免摘要把整条详情行占满。
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS: usize = 320;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -71,6 +86,27 @@ pub(crate) fn persist_agent_runtime_error(
"detail": safe_detail,
"metadata": metadata,
});
// 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData
// 应用日志,诊断包拿不到 detail。这里先把同一份已脱敏诊断留进应用日志,再落项目文件,
// 于是 sidecar 写失败也仍然留下可提交的诊断。
let app_log_lines = agent_runtime_error_app_log_lines(
root,
&event_id,
client_turn_id,
source,
stage,
code,
retryable,
public_text,
recovery_hint,
&detail_ref,
&safe_detail,
elapsed_ms,
&metadata,
);
for line in app_log_lines {
app_log!("{line}");
}
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
@@ -96,6 +132,78 @@ pub(crate) fn persist_agent_runtime_error(
})
}
/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。
///
/// 传进来的 `detail` 是 sidecar 用的脱敏文本,`metadata` 则从未脱敏过。两行落到 `app_log!`
/// 时都会先按应用日志预算(1200 / 200 字符)再脱敏、再截断:`app_log!` 还会把同一行写到
/// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本
/// `summary` 按 320 字符预算)都在这里过一遍脱敏。
///
/// 已经脱敏过的 `detail` 也照走同一遍流水线,不按「调用方已脱敏」走短路:截断会把
/// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。
///
/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被
/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或
/// 调用方常量字段**eventId / source / stage / code / retryable / clientTurnId / elapsedMs /
/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词
/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。
///
/// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行,
/// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先
/// 过一遍 [`single_line_log_field`],不假设调用方一定给单行文本。
pub(crate) fn agent_runtime_error_app_log_lines(
root: &Path,
event_id: &str,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail_ref: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: &Value,
) -> [String; 2] {
let event_id = single_line_log_field(event_id);
let client_turn_id = single_line_log_field(client_turn_id.unwrap_or("none"));
let source = single_line_log_field(source);
let stage = single_line_log_field(stage);
let code = single_line_log_field(code);
let detail_ref = single_line_log_field(detail_ref);
let public_text = single_line_log_field(&redact_agent_runtime_error(
root,
public_text,
AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS,
));
let elapsed_ms = elapsed_ms
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string());
let identity = format!(
"agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref}"
);
let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS);
let metadata = redact_agent_runtime_error(
root,
&metadata.to_string(),
AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS,
);
let detail_line = format!(
"agent.runtime.error.detail eventId={event_id} hint={} summary={} detail={} metadata={}",
single_line_log_field(recovery_hint),
single_line_log_field(&public_text),
single_line_log_field(&detail),
single_line_log_field(&metadata),
);
[identity, detail_line]
}
/// 应用日志是逐行读取的:落到日志里的自由文本必须先压平换行。
fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -153,6 +261,91 @@ mod tests {
assert!(!text.contains("token=secret"));
}
#[test]
fn error_event_app_log_lines_keep_identity_and_redact_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
// 生产路径传进来的是已经脱敏的 safe_detail(8 KiB 口径),这里按同一口径造输入;
// metadata 在生产里从未脱敏,仍按原文传。
let safe_detail = redact_agent_runtime_error(
&root,
"C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断",
AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS,
);
let lines = agent_runtime_error_app_log_lines(
&root,
"error-1-1",
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件\n附带换行",
"查看诊断后重试",
".agent/runtime/errors/error-1-1.json",
&safe_detail,
Some(1200),
&serde_json::json!({"authorization": "Bearer secret"}),
);
for line in &lines {
assert!(!line.contains('\n'), "{line}");
}
// 落盘边界按真实口径核验:应用日志逐行脱敏、逐行截断,两行不会先拼在一起。
let persisted = lines
.iter()
.map(|line| crate::sanitize_diagnostic_message(line, None))
.collect::<Vec<_>>();
for line in &persisted {
assert!(!line.contains("token=secret"), "{line}");
assert!(line.chars().count() <= 2_048, "{line}");
}
let identity = &persisted[0];
assert!(identity.contains("eventId=error-1-1"), "{identity}");
assert!(identity.contains("code=turn-idle-timeout"), "{identity}");
assert!(
identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"),
"{identity}"
);
// 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。
assert!(!identity.contains("summary="), "{identity}");
let detail = &persisted[1];
assert!(
detail.contains("summary=本轮没有收到完成事件 附带换行"),
"{detail}"
);
assert!(
detail.contains("detail=<absolute-path> <redacted-url> 第二行诊断"),
"{detail}"
);
assert!(!detail.contains("Bearer secret"), "{detail}");
// 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。
let marked = agent_runtime_error_app_log_lines(
&root,
"error-3-1",
None,
"agc-tools",
"tool-execution",
"tool-error",
true,
"credential rotation failed",
"查看项目错误诊断后处理",
".agent/runtime/errors/error-3-1.json",
&safe_detail,
None,
&serde_json::json!({"tool": "agc_tools"}),
);
let identity = crate::sanitize_diagnostic_message(&marked[0], None);
assert!(identity.contains("eventId=error-3-1"), "{identity}");
assert!(identity.contains("code=tool-error"), "{identity}");
assert_eq!(
crate::sanitize_diagnostic_message(&marked[1], None),
"<sensitive diagnostic details redacted>"
);
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
@@ -2,6 +2,11 @@ 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<Mutex<()>> = OnceLock::new();
fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> {
@@ -1743,20 +1748,6 @@ fn process_session_runner_owner_fixture() {
#[cfg(target_os = "linux")]
#[test]
fn process_session_owner_sigkill_leaves_no_child_process() {
fn project_processes(root: &Path) -> Vec<i32> {
let canonical_root = fs::canonicalize(root).expect("canonical test project");
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "owner-process-project", "Owner Process Project")
@@ -1787,6 +1778,10 @@ setInterval(() => {}, 1000);
.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
@@ -1799,9 +1794,9 @@ setInterval(() => {}, 1000);
"sandbox child should be visible from host /proc"
);
let owner_pid = i32::try_from(owner.id()).expect("owner pid");
assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0);
owner.wait().expect("reap owner fixture");
// 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);
@@ -0,0 +1,127 @@
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<i32> {
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::<i32>().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"
);
}
}