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
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:
@@ -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);
|
||||
|
||||
+127
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user