From 8b2d0cb9df83b040fb3c29ae62130c730a6106e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 21:34:45 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=90=8C=E6=AD=A5=E7=95=99=E7=97=95=E5=88=B0?= =?UTF-8?q?=20AppData=20=E5=BA=94=E7=94=A8=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent_runtime_error_app_log_lines,把统一错误事件的同一份已脱敏诊断投影成应用日志两行:agent.runtime.error 身份行与 agent.runtime.error.detail 详情行 - 身份行含 eventId/source/stage/code/retryable/clientTurnId/elapsedMs/detailRef/summary,详情行含 hint/detail/metadata;detail 与 metadata 各按 1200/200 字符预算先脱敏再截断,字段仍只来自 sidecar 那份 diagnosis - 拆两行的原因:整行命中凭据标记会被 sanitize_diagnostic_message 整体替换成脱敏占位,详情行被吃掉时身份行仍能定位 eventId 与 detailRef - app_log! 先于 sidecar 写入,sidecar 写失败也留下可提交的诊断;各字段先压平换行,保证应用日志逐行读取且 stderr 输出不拆行 - 新增用例覆盖身份字段、凭据脱敏、单行口径与实际 sanitize_diagnostic_message 落盘边界(含超长诊断) - 同步文件【技术方案】AGC错误报告与诊断上传与【技术方案】AI游戏创作智能体App实施计划,共享记忆 decision-log 与 pitfalls 明确项目内 .agent/runtime/errors sidecar 与进程内错误报告事件池是两套东西,本次只写日志行 - 验证:cargo test runtime_error(7 passed)、cargo fmt --check、npm run check:encoding、git diff --check --- .../src-tauri/src/agent/runtime_error.rs | 132 +++++++++++++++++- .../shared-memory/decision-log.md | 9 ++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 5 files changed, 151 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 08230226d..3f67baed8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -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,14 @@ 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; + static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] @@ -71,6 +80,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 +126,65 @@ pub(crate) fn persist_agent_runtime_error( }) } +/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。 +/// +/// 传进来的 `detail` 已经是 sidecar 用的脱敏文本;这里只再按应用日志的预算截一次, +/// 让日志与项目内 sidecar 是同一份诊断,不再单独拼一套字段。 +/// +/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 +/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; +/// 身份行保持短小且只含摘要与引用,保证事件还能被定位。 +/// +/// 单行口径在这里落地:`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, + 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(public_text); + 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} summary={public_text}" + ); + 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={} detail={} metadata={metadata}", + single_line_log_field(recovery_hint), + single_line_log_field(&detail), + ); + [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 +242,47 @@ 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"); + 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", + "C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断", + Some(1200), + &serde_json::json!({"authorization": "Bearer secret"}), + ); + for line in &lines { + assert!(!line.contains('\n'), "{line}"); + } + // 落盘边界按真实口径核验:应用日志的整行脱敏既不能吃掉身份字段,也不能靠换行拆行。 + let persisted = crate::sanitize_diagnostic_message(&lines.join(" "), None); + assert!(persisted.contains("eventId=error-1-1"), "{persisted}"); + assert!(persisted.contains("code=turn-idle-timeout"), "{persisted}"); + assert!( + persisted.contains("detailRef=.agent/runtime/errors/error-1-1.json"), + "{persisted}" + ); + assert!(persisted.contains("summary=本轮没有收到完成事件 附带换行")); + assert!(persisted.contains("detail= 第二行诊断")); + assert!( + !persisted.contains("token=secret") && !persisted.contains("Bearer secret"), + "{persisted}" + ); + assert!(persisted.chars().count() <= 2_048, "{persisted}"); + } + #[test] fn timeout_and_tool_errors_have_distinct_codes() { assert_eq!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e708d7af2..fc72b0b57 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9173,3 +9173,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasFocusModel.ts`、`tests/{projectResourceLiveIntegration,resourceCanvasQuickEditDraft,resourceCanvasFloatingDismiss}.test.tsx`、PRD §3.10。 - 验证:定向 `projectResourceLiveIntegration`(32 条,三条断言面板留在失败态的用例按新口径改写为「重开面板再重试,身份不变」)、`resourceCanvasQuickEditDraft`(10 条)、`resourceCanvasFloatingDismiss`(18 条)全绿;`npm --prefix apps/ai-game-creator-shell run typecheck` 通过。 - 合并前复核(2026-09-21):合并 master 后按**仓库根**跑全量 `npx vitest run`,**373 个测试文件全过、4512 通过 / 34 跳过 / 0 失败**;PR #419 显示 `No Conflicts`。真实客户端观感与远程 CI 未复验(后者按用户要求不追,runner/镜像问题见 Issue #431)。 + +## 2026-09-21 统一错误事件额外投影到 AppData 应用日志 + +- 背景:`direct-codex-failure:v2 ... 详情:.agent/runtime/errors/error--9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。 +- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;detail / metadata 先按应用日志预算(1200 / 200 字符)脱敏截断,自由文本先压平换行(`app_log!` 同时写 stderr,那里没人压行)。 +- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。 +- 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/.json` sidecar 是两套东西——本次只把 sidecar 的同一份诊断作为**应用日志行**落盘,不进事件池、不改报告上传协议、不改 `read_agent_runtime_error_detail` 详情入口。 +- 影响面:只改 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs` 这一处写入边界(覆盖 direct-codex / agc-tools / agent-runtime 三类来源);sidecar schema、对话投影、前端详情入口与错误报告协议均未改动。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error`(7 passed,含新增 `error_event_app_log_lines_keep_identity_and_redact_detail`)与 `-- direct_runtime direct_tool_bridge`(131 passed)全绿;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 14b72578c..5ea414011 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5851,3 +5851,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:① 依赖里只放数据,回调走 ref(`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。 - **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。 - **关联**:`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`。 + +## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 + +`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时先按两行落:身份行短小、详情行独立,且自由文本先自行压平换行)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index c077ab88e..d13a14f72 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -13,6 +13,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 +- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,detail 与 metadata 各按应用日志预算截断,不新增字段来源;详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5417661f7..8f55a9280 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1831,3 +1831,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。 - 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。 - 大项目(素材数量多、单文件大)的首轮全量上传耗时与带宽占用未实测;单次预算 512 MiB 会把超出部分留到下一次同步,但并发上限与断点续传仍未引入。 + +## 2026-09-21 统一错误事件同时落到 AppData 应用日志 + +`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary`)与 `agent.runtime.error.detail`(详情行:`hint / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 + +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`detail` 与 `metadata` 按应用日志预算(1200 / 200 字符)先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位,详情行可能消失,身份行至少要保住事件定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 -- 2.52.0 From 852f5de886502e4eb97a94509f07d759689f3d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:12:41 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=BA=AB=E4=BB=BD=E8=A1=8C=E7=9A=84=E6=91=98?= =?UTF-8?q?=E8=A6=81=E5=8F=AA=E5=8E=8B=E8=A1=8C=E6=9C=AA=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 身份行的 summary 由调用方给,direct_tool_bridge 传的是工具错误原文,而 app_log! 会把整行写 stderr,那里没有 sanitize_diagnostic_message 兜底 - 新增 AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS(320 字符,与 sidecar 摘要预算同口径),summary 落日志前先脱敏再截断 - 函数合同写明 detail/metadata/summary 三类外来文本都在这里脱敏,不再只是压平换行 - 同步【技术方案】AGC错误报告与诊断上传与共享记忆 decision-log 的预算口径 - 验证:cargo test runtime_error(7 passed) --- .../src-tauri/src/agent/runtime_error.rs | 18 +++++++++++++++--- .../shared-memory/decision-log.md | 2 +- ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 3f67baed8..891fff007 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -23,6 +23,12 @@ pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; 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)] @@ -128,8 +134,10 @@ pub(crate) fn persist_agent_runtime_error( /// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。 /// -/// 传进来的 `detail` 已经是 sidecar 用的脱敏文本;这里只再按应用日志的预算截一次, -/// 让日志与项目内 sidecar 是同一份诊断,不再单独拼一套字段。 +/// 传进来的 `detail` 是 sidecar 用的脱敏文本,`metadata` 则从未脱敏过。两行落到 `app_log!` +/// 时都会先按应用日志预算(1200 / 200 字符)再脱敏、再截断:`app_log!` 还会把同一行写到 +/// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本 +/// (`summary` 按 320 字符预算)都在这里过一遍脱敏。 /// /// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 /// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; @@ -159,7 +167,11 @@ pub(crate) fn agent_runtime_error_app_log_lines( 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(public_text); + 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()); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fc72b0b57..5c2046cdc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9177,7 +9177,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-21 统一错误事件额外投影到 AppData 应用日志 - 背景:`direct-codex-failure:v2 ... 详情:.agent/runtime/errors/error--9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。 -- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;detail / metadata 先按应用日志预算(1200 / 200 字符)脱敏截断,自由文本先压平换行(`app_log!` 同时写 stderr,那里没人压行)。 +- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 detail / metadata 按应用日志预算(1200 / 200 字符)脱敏截断,身份行的 `summary` 也按 320 字符预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 - 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。 - 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/.json` sidecar 是两套东西——本次只把 sidecar 的同一份诊断作为**应用日志行**落盘,不进事件池、不改报告上传协议、不改 `read_agent_runtime_error_detail` 详情入口。 - 影响面:只改 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs` 这一处写入边界(覆盖 direct-codex / agc-tools / agent-runtime 三类来源);sidecar schema、对话投影、前端详情入口与错误报告协议均未改动。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index d13a14f72..1eeed278b 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -13,7 +13,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 -- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,detail 与 metadata 各按应用日志预算截断,不新增字段来源;详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 +- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 detail / metadata 各按应用日志预算(1200 / 200 字符)脱敏截断,身份行里的 `summary` 也按 320 字符预算脱敏截断(该字段由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 -- 2.52.0 From c49ee8d157de05f1bf3bd19bfc9c589bd852c1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:16:05 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=A1=8C=E7=9A=84=20meta?= =?UTF-8?q?data=20=E8=A1=A5=E4=B8=8A=E5=8D=95=E8=A1=8C=E5=8E=8B=E5=B9=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metadata 之前直接以 {metadata} 插值进日志行,只靠 Value::to_string() 的转义保证单行,和函数「每个字段都先压平」的约定不一致 - 改为与其他字段一样走 single_line_log_field,日志行完整性不再依赖序列化器的转义行为 - 验证:cargo test runtime_error(7 passed) --- .../ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 891fff007..8a39c95ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -185,9 +185,10 @@ pub(crate) fn agent_runtime_error_app_log_lines( AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS, ); let detail_line = format!( - "agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={metadata}", + "agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={}", single_line_log_field(recovery_hint), single_line_log_field(&detail), + single_line_log_field(&metadata), ); [identity, detail_line] } -- 2.52.0 From 58475050209063c937b28a54674960e25e8e5e35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:20:25 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=8C=89=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E5=8F=A3=E5=BE=84=E6=A0=B8=E9=AA=8C=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=90=BD=E7=9B=98=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生产是把两行分别交给 append_application_log_line 脱敏截断,原用例先 join(" ") 再 sanitize,测不到逐行截断与「只该脱敏其中一行」的情况 - 改为逐行 sanitize,并分别断言身份行(eventId/code/detailRef/summary)与详情行(detail 脱敏、metadata 无凭据) - 验证:cargo test runtime_error(7 passed) --- .../src-tauri/src/agent/runtime_error.rs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 8a39c95ef..bf0d8103e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -279,21 +279,32 @@ mod tests { for line in &lines { assert!(!line.contains('\n'), "{line}"); } - // 落盘边界按真实口径核验:应用日志的整行脱敏既不能吃掉身份字段,也不能靠换行拆行。 - let persisted = crate::sanitize_diagnostic_message(&lines.join(" "), None); - assert!(persisted.contains("eventId=error-1-1"), "{persisted}"); - assert!(persisted.contains("code=turn-idle-timeout"), "{persisted}"); + // 落盘边界按真实口径核验:应用日志逐行脱敏、逐行截断,两行不会先拼在一起。 + let persisted = lines + .iter() + .map(|line| crate::sanitize_diagnostic_message(line, None)) + .collect::>(); + 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!( - persisted.contains("detailRef=.agent/runtime/errors/error-1-1.json"), - "{persisted}" + identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"), + "{identity}" ); - assert!(persisted.contains("summary=本轮没有收到完成事件 附带换行")); - assert!(persisted.contains("detail= 第二行诊断")); assert!( - !persisted.contains("token=secret") && !persisted.contains("Bearer secret"), - "{persisted}" + identity.contains("summary=本轮没有收到完成事件 附带换行"), + "{identity}" ); - assert!(persisted.chars().count() <= 2_048, "{persisted}"); + let detail = &persisted[1]; + assert!( + detail.contains("detail= 第二行诊断"), + "{detail}" + ); + assert!(!detail.contains("Bearer secret"), "{detail}"); } #[test] -- 2.52.0 From 4bdf9e2cd4ba3f8a24f21d33972ab66f37ff5b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:42:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=86=99=E6=98=8E=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=8A=95=E5=BD=B1=E4=B8=BA=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E5=AF=B9=E5=B7=B2=E8=84=B1=E6=95=8F=20detail=20=E5=86=8D?= =?UTF-8?q?=E8=BF=87=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原注释写「只再按应用日志预算截一次」,实现却是完整脱敏流水线,合同与实现不一致 - 采纳方式是把合同写实:detail/metadata/summary 都在这里脱敏;已脱敏的 detail 也走同一遍,理由是截断会切开脱敏标记,且这是 pub(crate) 边界不假设调用方先脱敏 - 未采纳「去掉再脱敏改为只截断」:该建议会把安全性绑定在调用方自觉上,收益只是一次可忽略的 canonicalize - 验证:rustfmt(注释改动,无行为变化) --- .../ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index bf0d8103e..b6bfab1fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -139,6 +139,9 @@ pub(crate) fn persist_agent_runtime_error( /// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本 /// (`summary` 按 320 字符预算)都在这里过一遍脱敏。 /// +/// 已经脱敏过的 `detail` 也照走同一遍流水线,不按「调用方已脱敏」走短路:截断会把 +/// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。 +/// /// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 /// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; /// 身份行保持短小且只含摘要与引用,保证事件还能被定位。 -- 2.52.0 From cc980267fe9b53ff10e0569b5d98a3b2324378ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:45:06 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E7=94=A8=E4=BE=8B=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=E7=94=9F=E4=BA=A7=E5=8F=A3=E5=BE=84=E4=BC=A0=E5=85=A5?= =?UTF-8?q?=E5=B7=B2=E8=84=B1=E6=95=8F=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原用例直接传带 token=secret 的原文,与函数「detail 已是 sidecar 脱敏文本」的前置条件不符,测的不是真实数据流 - 改为先按 8 KiB 口径 redact 出 safe_detail 再传入,metadata 仍按生产原样传未脱敏 JSON,保留脱敏覆盖 - 验证:cargo test runtime_error(7 passed) --- .../src-tauri/src/agent/runtime_error.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index b6bfab1fc..8b1f7d6dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -264,6 +264,13 @@ mod tests { 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", @@ -275,7 +282,7 @@ mod tests { "本轮没有收到完成事件\n附带换行", "查看诊断后重试", ".agent/runtime/errors/error-1-1.json", - "C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断", + &safe_detail, Some(1200), &serde_json::json!({"authorization": "Bearer secret"}), ); -- 2.52.0 From 74e6ac90a7528952a7c55004d64313f2c5aace69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 10:22:28 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=8A=8A=E6=91=98=E8=A6=81=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E5=BA=94=E7=94=A8=E6=97=A5=E5=BF=97=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E9=81=BF=E5=85=8D=E6=95=B4=E8=A1=8C=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E5=90=83=E6=8E=89=E4=BA=8B=E4=BB=B6=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 身份行只留程序生成或调用方常量字段(eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef),去掉了 summary - summary 移到详情行(hint / summary / detail / metadata),自由文本全部只出现在详情行 - 原因:sanitize_diagnostic_message 命中凭据标记时替换整行,且裸词标记(如 credential rotation failed)脱敏消不掉,summary 留在身份行会连 eventId、detailRef 一起被替换 - 用例补一条裸标记词场景:详情行被整体替换时身份行仍能按 eventId 与 code 定位 - 同步【技术方案】AGC错误报告与诊断上传、【技术方案】AI游戏创作智能体App实施计划、决策记录与踩坑记录的两行字段口径 - 验证:cargo test -- runtime_error direct_tool_bridge(41 passed)、cargo fmt --check、git diff --check --- .../src-tauri/src/agent/runtime_error.rs | 51 +++++++++++++++---- .../shared-memory/decision-log.md | 4 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 8b1f7d6dc..8f83bfc4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -23,10 +23,10 @@ pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; 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 的字符预算。 +/// 详情行里 public summary 的字符预算。 /// -/// 身份行必须短小:`summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文, -/// 所以这里与 sidecar 侧的摘要预算同口径(320 字符)截断,并再脱敏一次。 +/// `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); @@ -143,8 +143,10 @@ pub(crate) fn persist_agent_runtime_error( /// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。 /// /// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 -/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; -/// 身份行保持短小且只含摘要与引用,保证事件还能被定位。 +/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或 +/// 调用方常量字段**(eventId / source / stage / code / retryable / clientTurnId / elapsedMs / +/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词 +/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。 /// /// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行, /// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先 @@ -179,7 +181,7 @@ pub(crate) fn agent_runtime_error_app_log_lines( .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} summary={public_text}" + "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( @@ -188,8 +190,9 @@ pub(crate) fn agent_runtime_error_app_log_lines( AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS, ); let detail_line = format!( - "agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={}", + "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), ); @@ -305,16 +308,42 @@ mod tests { identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"), "{identity}" ); - assert!( - identity.contains("summary=本轮没有收到完成事件 附带换行"), - "{identity}" - ); + // 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。 + assert!(!identity.contains("summary="), "{identity}"); let detail = &persisted[1]; + assert!( + detail.contains("summary=本轮没有收到完成事件 附带换行"), + "{detail}" + ); assert!( detail.contains("detail= 第二行诊断"), "{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), + "" + ); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5c2046cdc..38d241c33 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9177,8 +9177,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-21 统一错误事件额外投影到 AppData 应用日志 - 背景:`direct-codex-failure:v2 ... 详情:.agent/runtime/errors/error--9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。 -- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 detail / metadata 按应用日志预算(1200 / 200 字符)脱敏截断,身份行的 `summary` 也按 320 字符预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 -- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。 +- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 +- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。后续复核发现:裸词标记(例如 `credential rotation failed`)脱敏消不掉,summary 留在身份行时仍会连 eventId 一起被替换,因此 summary / hint 等自由文本一律只放详情行,身份行只留程序生成与调用方常量字段。 - 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/.json` sidecar 是两套东西——本次只把 sidecar 的同一份诊断作为**应用日志行**落盘,不进事件池、不改报告上传协议、不改 `read_agent_runtime_error_detail` 详情入口。 - 影响面:只改 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs` 这一处写入边界(覆盖 direct-codex / agc-tools / agent-runtime 三类来源);sidecar schema、对话投影、前端详情入口与错误报告协议均未改动。 - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error`(7 passed,含新增 `error_event_app_log_lines_keep_identity_and_redact_detail`)与 `-- direct_runtime direct_tool_bridge`(131 passed)全绿;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5ea414011..ba88d60ab 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5854,4 +5854,4 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 -`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时先按两行落:身份行短小、详情行独立,且自由文本先自行压平换行)。 +`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 1eeed278b..74f326266 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -13,7 +13,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 -- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 detail / metadata 各按应用日志预算(1200 / 200 字符)脱敏截断,身份行里的 `summary` 也按 320 字符预算脱敏截断(该字段由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 +- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8f55a9280..c2d059a62 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1834,6 +1834,6 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ## 2026-09-21 统一错误事件同时落到 AppData 应用日志 -`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary`)与 `agent.runtime.error.detail`(详情行:`hint / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 +`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef`)与 `agent.runtime.error.detail`(详情行:`hint / summary / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 -口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`detail` 与 `metadata` 按应用日志预算(1200 / 200 字符)先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位,详情行可能消失,身份行至少要保住事件定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`summary` 按 320 字符、`detail` 与 `metadata` 按(1200 / 200 字符)预算先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位:所以**自由文本(summary / hint / detail)只放详情行**,身份行只留程序生成与调用方常量字段,详情行被整体脱敏时事件仍能按 eventId / detailRef 定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 -- 2.52.0