feat(cli): M242 —— ull diag 与 AI diagnose_crash 改读会话里落盘的结论

两个入口原来都是「读回会话事件 → 在**已脱敏**文本上重跑规则」。脱敏一旦有损
(`≥64` 不透明串、带点票据串 → 占位符),重跑出来的不是「少一点细节」,而是
**换一条结论**:`fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found`
(warning,把「Fabric Loader 版本不配套」说成「库没下全」)。M241 起写会话时已经在
原始行上算过结论并落成 `diagnosis` 事件(且过 `scrub_json`),所以两个入口改成
**优先读落盘那份**,只有没有该事件的老会话才回落到重跑。规则 pattern 一个字符没动。

- `commands/sessions/diagnosis.rs`(新):结论来源选择(stored / rerun)、人类输出渲染;
  `ull diag` 的 payload 增加 `conclusion_source`,人类输出多一行「结论来源」。
- `commands/sessions.rs`:`diag` 瘦成取结论 + 拼 payload(240 行级 → 拆文件守 ≤400 行)。
- `commands/agent/ports/diagnose.rs`:`diagnose_crash` 同改;`read_game_log` 与
  `CrashDiagnosis.evidence` 仍是脱敏文本(隐私选择不变),只换结论来源。
- 用例(真 `SessionWriter` 写盘 → 真 CLI `crate::run` / 真工具注册 + handler):
  `sessions/m242_tests.rs`、`agent/ports/diagnose/tests.rs`;含老会话回落用例。
  变异自证:关掉落盘分支 ⇒ 两条用例红(`rerun`/`integrity.no_class_def_found`),已还原。
This commit is contained in:
AskaEth committed 2026-11-11 10:34:18 +08:00
1 parent d5a6f5dc50
commit 1a06163407
5 files changed
+840 -156

No files matched your search

@@ -0,0 +1,240 @@
//! `ull diag` 的结论来源:**先读会话里落盘的那份**,老会话才重跑规则(M242)。
//!
//! ## 为什么不能每次都在会话文本上重跑
//!
//! 会话 JSONL 是**写前脱敏**的(`launch::redact`,落盘即脱敏),而脱敏拿不准形状时是**有损**的:
//! `≥64` 的不透明串、带点的票据串都会被换成占位符(`<redacted:opaque>` / `<redacted:token>`)。
//! M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的那条
//! `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**:形状一变
//! (或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」,是
//! **换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found`
//! (warning:把「Fabric Loader 版本不配套」说成「库没下全」)。
//!
//! M241 起写会话时已经在**原始行**上算过一次结论(`SessionWriter::finish` →
//! `diagnose::session::conclude`),落成 `diagnosis` 事件;那份结论自己还要再走一遍
//! `Redactor::scrub_json`,所以盘上只有脱敏后的结论,没有原文。M242 换的就是**证据**,
//! 不是判据:规则集的 pattern 一个字符都没动。
//!
//! ## 回落
//!
//! 没有 `diagnosis` 事件的老会话仍走原来的重跑(行为与 M242 之前完全一致):短字面量的规则
//! (`memory.out_of_memory` 之类)在脱敏文本上照样命中;只有「靠长串证据」那类规则会降级
//! —— 那是老数据本身的代价,输出里的「结论来源」一行会把它说清。
use serde_json::Value;
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::launch::session_index::SessionIndexEntry;
use crate::error::CliError;
use super::exit_label;
/// 结论从哪儿来(人类输出与 `--json` 都要说清,否则用户分不清「盘上的」与「刚算的」——
/// 这两者在长串证据类规则上给出的结论**可能不同**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
/// 会话里落盘的 `diagnosis` 事件(M241 起,写会话时在原始行上算的)。
Stored,
/// 老会话:在脱敏文本上重跑规则。
Rerun,
}
impl Provenance {
/// `--json` 里的值(`stored` / `rerun`)。
pub const fn as_str(self) -> &'static str {
match self {
Self::Stored => "stored",
Self::Rerun => "rerun",
}
}
/// 人类输出里的一行说明。
pub const fn label(self) -> &'static str {
match self {
Self::Stored => "会话落盘的 diagnosis 事件(写会话时在原始行上算的,已脱敏后落盘)",
Self::Rerun => "在脱敏文本上重跑规则(老会话没有落盘结论,靠长串证据的规则可能降级)",
}
}
}
/// 一次诊断:结论本体 + 出处。
#[derive(Debug, Clone)]
pub struct Conclusion {
/// `diagnosis` 记录(`DiagnosisRecord` 的 JSON),两条来源同形。
pub record: Value,
/// 从哪儿来。
pub provenance: Provenance,
/// 重跑时评估了多少条规则(落盘那份不记这个数,所以是 `Option`)。
pub rules_evaluated: Option<usize>,
}
/// 取这个会话的诊断结论:优先落盘的那份,老会话才重跑。
///
/// # Errors
///
/// 重跑路径上结论序列化失败时返回内部错误。
pub fn conclusion(
entry: &SessionIndexEntry,
events: &[Value],
tail: usize,
) -> Result<Conclusion, CliError> {
if let Some(record) = stored(events) {
return Ok(Conclusion {
record,
provenance: Provenance::Stored,
rules_evaluated: None,
});
}
let (record, rules_evaluated) = rerun(entry, events, tail)?;
Ok(Conclusion {
record,
provenance: Provenance::Rerun,
rules_evaluated: Some(rules_evaluated),
})
}
/// 事件流里最后一条 `diagnosis` 事件的 payload。
///
/// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。
/// 没有主因(`candidates` 空)的 payload 与「没结论」等价——`conclude` 也不会写它——此时
/// 回落到重跑,而不是拿一份空结论顶掉。
fn stored(events: &[Value]) -> Option<Value> {
let payload = events
.iter()
.rev()
.find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))?
.get("payload")?;
let has_primary = payload
.get("candidates")
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty());
has_primary.then(|| payload.clone())
}
/// 老会话的回落:在脱敏文本上重跑规则,返回 `(结论记录, 评估条数)`。
fn rerun(
entry: &SessionIndexEntry,
events: &[Value],
tail: usize,
) -> Result<(Value, usize), CliError> {
let mut stdout_lines: Vec<&str> = Vec::new();
let mut stderr_lines: Vec<&str> = Vec::new();
let mut error_lines: u32 = 0;
for event in events {
let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else {
continue;
};
match event.get("kind").and_then(Value::as_str) {
Some("stdout") => stdout_lines.push(line),
Some("stderr") => {
stderr_lines.push(line);
if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") {
error_lines += 1;
}
}
_ => {}
}
}
let stdout_text = tail_join(&stdout_lines, tail);
let stderr_text = tail_join(&stderr_lines, tail);
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
error_lines,
&stderr_text,
);
let mut input = DiagnosisInput::new().with_exit(exit);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
if !stderr_text.is_empty() {
input.push_text(SignalOrigin::Stderr, stderr_text);
}
let loader = RuleLoader::embedded();
let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec());
let report = diagnoser.analyze(&input);
let record = serde_json::to_value(report.to_record())
.map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?;
Ok((record, report.rules_evaluated))
}
/// 人类可读的几行(未脱敏——调用方统一过一次 `redact_text`)。
pub fn lines(entry: &SessionIndexEntry, found: &Conclusion) -> Vec<String> {
let mut human = vec![
format!(
"会话 {} · {} · {} · {}",
entry.session_id,
entry.instance_name,
entry.game_version,
exit_label(entry)
),
format!("结论来源: {}", found.provenance.label()),
];
match found.record.get("candidates").and_then(Value::as_array) {
Some(items) if !items.is_empty() => {
let primary = &items[0];
if let Some(message) = text_at(primary, "message") {
human.push(format!("主因: {message}"));
}
if let Some(detail) = text_at(primary, "detail") {
human.push(format!("说明: {detail}"));
}
for evidence in evidence_of(primary, 3) {
human.push(format!("证据: {evidence}"));
}
}
_ => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()),
}
human.push(counts_line(&found.record, found.rules_evaluated));
human
}
/// `规则评估 N 条 · 结论 N 条 · 次生 N 条`(落盘那份没有「评估条数」,省掉第一段)。
fn counts_line(record: &Value, rules_evaluated: Option<usize>) -> String {
let count = |key: &str| {
record
.get(key)
.and_then(Value::as_array)
.map_or(0, Vec::len)
};
let mut line = String::new();
if let Some(evaluated) = rules_evaluated {
line.push_str(&format!("规则评估 {evaluated} 条 · "));
}
line.push_str(&format!(
"结论 {} 条 · 次生 {} 条",
count("candidates"),
count("secondary")
));
line
}
fn text_at<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(Value::as_str)
}
fn evidence_of(candidate: &Value, limit: usize) -> Vec<String> {
candidate
.get("evidence")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.take(limit)
.map(str::to_string)
.collect()
}
/// 取末尾 `tail` 行并拼成文本(与重跑路径的老行为逐字一致)。
fn tail_join(lines: &[&str], tail: usize) -> String {
let start = lines.len().saturating_sub(tail.max(1));
let mut text = lines[start..].join("\n");
if !text.is_empty() {
text.push('\n');
}
text
}
@@ -0,0 +1,221 @@
//! M242:`ull diag <sid>` 的结论来源。两条端到端用例:**真 `SessionWriter` 写盘 → 真 CLI 入口**。
//!
//! 判据是「命中哪条规则」而不是「有没有结论」:脱敏有损,重跑出来的结论会**换一条**规则
//! (`fabric.loader_0_12` → `integrity.no_class_def_found`)。
//!
//! ## 夹具为什么把类路径登记成 secret
//!
//! M240 把「`≥64` 不透明串」这条收窄了:**路径形状**的长串(被 `/` 切开且每段都是词形名字)
//! 现在整条保留,所以 `SessionWriter` 落盘时这条证据行**今天不会被抹**。要复现「读回脱敏文本
//! 就丢了证据」这个世界(M239 实测的那份老落盘形状,也是脱敏规则以后再收紧时的形状),夹具
//! 把类路径登记成 secret 交给 `Redactor`:写盘前它被抹成 `<redacted:token>`,而结论是
//! 在**原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。
//!
//! 变异证明(改完已还原):
//! - 把 [`super::diagnosis::conclusion`] 里的 `stored(...)` 分支删掉(等于每次都重跑)⇒
//! `diag_reads_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use unlucky_core::launch::exit_analysis::{self, ExitSignals};
use unlucky_core::launch::pump::{LogLine, Stream};
use unlucky_core::launch::redact::Redactor;
use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter};
use unlucky_core::launch::session_index;
use crate::cli::Globals;
use crate::context::Ctx;
/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。
const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
/// 那条完整的证据行。
fn evidence_line() -> String {
format!("java.lang.NoClassDefFoundError: {CLASS_PATH}")
}
/// 一个临时数据根(`Drop` 时清掉,与 `crates/cli/tests/support` 同口径)。
struct TempRoot(PathBuf);
impl TempRoot {
fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("建临时数据根");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 与生产同一条解析(`--data-dir` → `Ctx`):会话根不在测试里手拼,避免「夹具说另一种形状」。
fn ctx_for(root: &Path) -> Ctx {
let globals = Globals {
data_dir: Some(root.to_path_buf()),
..Globals::default()
};
Ctx::resolve_in(&globals, None).expect("解析数据根")
}
/// 跑一次真 CLI 命令(`crate::run` = 解析 + `Ctx` + 命令实现),返回 `--json` 载荷的 `data`。
fn run_diag_json(root: &Path, session_id: &str) -> Value {
let args: Vec<String> = vec![
"diag".to_string(),
session_id.to_string(),
"--json".to_string(),
"--data-dir".to_string(),
root.display().to_string(),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = crate::run(&args, &mut out, &mut err);
let stdout = String::from_utf8_lossy(&out).into_owned();
let stderr = String::from_utf8_lossy(&err).into_owned();
assert_eq!(code, 0, "`ull diag` 应成功:{stderr}\n{stdout}");
let value: Value = serde_json::from_str(&stdout).unwrap_or_else(|error| {
panic!("`--json` 输出不是 JSON:{error}\n{stdout}");
});
value["data"].clone()
}
/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论 → 索引 + JSONL。
fn write_real_session(root: &Path) -> String {
let session_id = "20261102T090000-m242abc";
let mut writer = SessionWriter::create_with_id(
&ctx_for(root).sessions_root(),
session_id,
SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"),
// 登记成 secret ⇒ 盘上那条证据行的类路径被抹掉(见模块文档:为什么要人为造这份失真)。
Redactor::new().with_secret(CLASS_PATH),
)
.expect("建会话");
writer.session_start().expect("session_start");
let lines = vec![
LogLine::new(Stream::Stderr, "Failed to start Fabric loader"),
LogLine::new(Stream::Stderr, evidence_line()),
LogLine::new(Stream::Stdout, "[main/INFO]: Loading Minecraft 1.20.1"),
];
for line in &lines {
writer.line(line).expect("写行");
}
let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer.finish(EndReason::Exit).expect("finish");
session_id.to_string()
}
/// 落盘的会话文件(诊断事件也在这份里)。
fn event_file(root: &Path, session_id: &str) -> PathBuf {
session_index::event_file(
&ctx_for(root).sessions_root(),
&session_index::log_path_for(session_id),
)
}
/// 主判据:结论是**盘上那份**。只有读到 `diagnosis` 事件才能命中 `fabric.loader_0_12`——
/// 在脱敏文本上重跑永远命中不了它(证据行已被抹成占位符)。
#[test]
fn diag_reads_the_stored_conclusion_instead_of_rerunning_redacted_text() {
let temp = TempRoot::new("diag-stored");
let root = temp.path();
let session_id = write_real_session(root);
let data = run_diag_json(root, &session_id);
assert_eq!(data["session_id"], session_id.as_str());
assert_eq!(
data["conclusion_source"], "stored",
"有 diagnosis 事件时不得重跑:{data}"
);
assert_eq!(
data["diagnosis"]["candidates"][0]["id"], "fabric.loader_0_12",
"落盘的结论必须原样读出(重跑会降级成 integrity.no_class_def_found):{data}"
);
assert_eq!(data["diagnosis"]["needs_more_info"], false);
// 盘上那份**真的**丢了证据(不然这条用例证明不了「重跑会失真」),而修 bug ≠ 把原文写进盘。
let disk = std::fs::read_to_string(event_file(root, &session_id)).expect("读会话文件");
assert!(
disk.contains("<redacted:token>"),
"夹具前提:盘上的证据行必须已被抹掉:{disk}"
);
assert!(!disk.contains("FabricMixinTransformerProxy"), "{disk}");
// 人类路径也要说清结论从哪来(否则「盘上的」与「刚算的」分不开)。
let args: Vec<String> = vec![
"diag".to_string(),
session_id.clone(),
"--data-dir".to_string(),
root.display().to_string(),
];
let (mut out, mut err) = (Vec::new(), Vec::new());
assert_eq!(crate::run(&args, &mut out, &mut err), 0);
let human = String::from_utf8_lossy(&out);
assert!(
human.contains("结论来源: 会话落盘的 diagnosis 事件"),
"{human}"
);
assert!(human.contains("主因: "), "{human}");
}
/// 老会话兼容:没有 `diagnosis` 事件(M241 之前写的 JSONL,且还在旧家)→ 回落重跑仍然可用。
///
/// 与上一条的差别只在**证据**:这里盘上只有脱敏文本,所以用的是短字面量规则
/// `memory.out_of_memory`(脱敏抹不掉 `java.lang.OutOfMemoryError`)。长串证据那类规则
/// 在老会话上只能降级,这是老数据本身的代价——`conclusion_source` 会标成 `rerun`。
#[test]
fn legacy_session_without_a_stored_conclusion_falls_back_to_rerunning() {
let temp = TempRoot::new("diag-legacy");
let root = temp.path();
let session_id = "20261030T101500-legacy";
let log_path = session_index::log_path_for(session_id);
// M188 之前的生产落点(`data/agent/sessions`,只读兼容):老会话的「老」还多一层。
let legacy_root = ctx_for(root).legacy_sessions_root();
let index = session_index::index_path(&legacy_root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": session_id,
"instance_id": "inst-1",
"instance_name": "1.20.1",
"game_version": "1.20.1",
"started_at": "2026-10-30T10:15:00Z",
"ended_at": "2026-10-30T10:15:06Z",
"duration_ms": 5000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 2,
"log_path": log_path,
"crash_found": false,
"diagnosis_top": null
});
std::fs::write(&index, format!("{entry}\n")).expect("写索引");
let file = session_index::event_file(&legacy_root, &log_path);
std::fs::write(
&file,
concat!(
"{\"v\":1,\"seq\":0,\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.OutOfMemoryError: Java heap space\",\"level\":\"error\"}}\n",
"{\"v\":1,\"seq\":1,\"kind\":\"session_end\",\"payload\":{\"reason\":\"exit\"}}\n"
),
)
.expect("写老会话事件流");
let data = run_diag_json(root, session_id);
assert_eq!(
data["conclusion_source"], "rerun",
"没有 diagnosis 事件时必须回落重跑:{data}"
);
assert_eq!(
data["diagnosis"]["candidates"][0]["id"], "memory.out_of_memory",
"回落路径仍要给出结论:{data}"
);
}