文档先行:AGC 命令错误结构化与错误报告口径

- 新增 ADR:命令错误按具体变体结构化(ts-rs 导出)、报告池只收没人处理的错误、前端按 type 分流不匹配文案
- 更新【技术方案】AGC错误报告与诊断上传:采集口径改为"只收没有调用方处理的错误",补命令变体分流与 clientApi 边界
- 更新共享记忆 decision-log:记录本次口径与影响范围
- 更新共享记忆 pitfalls:记录"输错密码被当成客户端缺陷上报"的现象、根因与判据
- 修正 DirectTurnError 模块注释中"命令边界只给字符串"的过期描述,改为结构化拒单载荷
- docs/README.md 登记新 ADR
This commit is contained in:
2026-10-01 14:52:28 +08:00
parent 66e12cc3fd
commit fe200598c0
6 changed files with 112 additions and 4 deletions
@@ -0,0 +1,89 @@
# 【ADR】AGC 命令错误结构化与错误报告口径
状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与
[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md))
## 背景
用户在登录页把密码输错一次,报告面板就出现两条"错误事件"并弹出「发现问题」:
```text
登录失败:密码长度需要在 6 到 128 位之间 — auth · 1 次
手机号或密码错误 — auth · 1 次
```
根因不是文案,而是两件事叠加:
1. 登录已经下沉到 Rust(`login_client_with_password` 等命令),命令失败以 `Err(String)` 返回;
Tauri 把 `String` 原样交给 JS,`invoke` 以**裸字符串**拒绝,前端拿到的东西没有任何类型信息。
2. WebView 侧的 `shouldCaptureClientError` 对"非 object"值走默认 `return true`,于是
`handleLoginSubmit` 的 catch 把"用户输错密码"当成缺陷事件报进了错误池。
这与 [`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)
已写明的"预期的 4xx 登录/鉴权失败不进入错误报告池"直接冲突——口径早就定了,是错误通道的实现方式违背了它。
DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) §4):
命令返回结构化 typed error,前端按变体分流,"认不得的变体或非结构化错误"原样抛出走上报链路。
本 ADR 把这套口径推广成 AGC 命令错误的通用约定,并同时收窄错误报告池的入口。
## 决策
### 1. 命令错误按具体变体建模,不按文案匹配
- Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` +
`#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到
`apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。
- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。
这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。
- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用
`BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordEntryInputRejected`),
不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。
- 每个变体带一份可展示 `message`,文案仍只在 Rust 生成一次;前端不拼文案。
### 2. 报告池只收"没有任何调用方处理"的错误
谁抛出、谁判定。分层规则:
- **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。
- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文重抛,
经 `window.onerror` / `unhandledrejection` 入池;Rust 侧 agent 终态失败仍由失败投影入池。
- **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。
- **API 客户端(`clientApi`)在抛出前判定 408/5xx/网络为缺陷**:它是 `fetch` 的调用方,
这一判定就发生在它这一层;4xx 一律不报,交给上层调用方。这条边界保持现状,不放宽也不收紧。
### 3. 前端按变体分流,认不出就抛
- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorNotice` 用
`switch (error.type)` 给出可展示文案;`default → null` 表示"认不出"。
- 认不出、系统类、非结构化拒绝 → `throw new ClientActionError(message, context, cause)`;
`ClientActionError` 只承载 `source/action/page` 上下文与 `cause`,由全局 handler 用
`instanceof` 解包后入池(指纹/展示字段与今天一致)。
- 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。
### 4. 报告面板与通知行为不变
默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变;
本次只保证"不该进池的东西不再进池"。
## 后果与边界
- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`)
及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为
`Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、
`logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。
- 未识别变体上调是**故意**的:Rust 与 TS 同包发布,"认不出"意味着有人加了变体忘了接界面,属于缺陷。
- 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`)
在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。
## 验收
```text
npm run ai-game-creator-shell:typecheck
npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings
npm run ai-game-creator-shell:check:rust:shell
npm run check:generated-bindings
npm run check:doc-index
npm run check:encoding
git diff --check
```