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

+172 -74
View File
@@ -2,9 +2,25 @@
//!
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
//!
//! ## M242:结论读**会话里落盘的那份**,不在脱敏文本上重跑
//!
//! 会话 JSONL 是写前脱敏的,而脱敏拿不准形状时是**有损**的:`≥64` 的不透明串、带点的票据串
//! 都会被换成占位符。M240 收窄了这条规则——**路径形状**的长串现在整条保留,所以 M239 实测的
//! 那条 `org/spongepowered/…/FabricMixinTransformerProxy` 今天能活下来。但那是**白名单**:
//! 形状一变(或又登记了别的 secret)就回到「抹掉」那一侧,而抹掉之后的重跑不是「少一点细节」,
//! 是**换一条结论** —— `fabric.loader_0_12`(critical)降级成 `integrity.no_class_def_found`
//! (warning),把「Fabric Loader 版本不配套」说成「库没下全」。M241 起写会话时已经在
//! **原始行**上算过一次结论并落成 `diagnosis` 事件,这里优先读它;没有该事件的老会话才回落到
//! 重跑(与今天的读法完全一致)。
//!
//! **隐私选择不变**:给模型的游戏日志仍是脱敏文本(`read_game_log` 与这里的 `evidence`
//! 片段都没动)。变的只是「结论从哪儿来」——盘上那份结论自己也是写入时过 `scrub_json` 的,
//! 所以既没把原文写进盘,也没把原文交给模型。
use std::collections::BTreeMap;
use serde_json::Value;
use unlucky_agent::port::{
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
PortResult,
@@ -13,12 +29,20 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
use unlucky_core::diagnose::loader::RuleLoader;
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
use unlucky_core::launch::session_index::SessionIndexEntry;
use super::events::{tail_join, SessionSource};
#[cfg(test)]
#[path = "diagnose/tests.rs"]
mod tests;
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
pub const DEFAULT_TAIL: usize = 2000;
/// 会话级证据片段条数(与重跑路径同值:`stderr` 末尾 3 行)。
const EVIDENCE_LINES: usize = 3;
/// M15 规则引擎的端口实现。
#[derive(Debug, Clone)]
pub struct CoreDiagnose {
@@ -82,12 +106,159 @@ fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
}
}
// ─────────────────── 落盘的结论(`diagnosis` 事件) ───────────────────
//
// 下面三个是 `unlucky_core::diagnose::finding::{DiagnosisRecord, Candidate,
// MaterializedFixAction}` 的**只读镜像**:core 侧那些类型只 derive `Serialize`(写盘用),
// agent 侧的类型也只 derive `Serialize`(回灌给模型用),所以「读回来」这一件事在这里落一次。
/// `diagnosis` 事件的 payload。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredRecord {
#[serde(default)]
source: Option<String>,
#[serde(default)]
needs_more_info: bool,
#[serde(default)]
candidates: Vec<StoredCandidate>,
#[serde(default)]
secondary: Vec<StoredCandidate>,
}
/// 一条落盘的候选结论。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredCandidate {
#[serde(default)]
id: String,
#[serde(default)]
severity: String,
#[serde(default)]
category: String,
#[serde(default)]
confidence: f32,
#[serde(default)]
message_key: String,
#[serde(default)]
message: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
evidence: Vec<String>,
#[serde(default)]
fix_actions: Vec<StoredAction>,
}
/// 一条落盘的修复动作(`kind` 是 `snake_case` 的六维之一)。
#[derive(Debug, Default, serde::Deserialize)]
struct StoredAction {
#[serde(default)]
kind: String,
#[serde(default)]
tool: String,
#[serde(default)]
args: BTreeMap<String, String>,
}
impl StoredRecord {
/// 事件流里最后一条**有主因**的 `diagnosis` 事件。
///
/// 正常只有一条(挂钩写的或收尾补的,二选一);真有多条取**最后一条**:收尾补的证据最新鲜。
/// 没有候选的 payload 与「没结论」等价(`conclude` 也不会写它)→ 返回 `None`,让调用方回落重跑。
fn find(events: &[Value]) -> Option<Self> {
let payload = events
.iter()
.rev()
.find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))?
.get("payload")?;
let record: Self = serde_json::from_value(payload.clone()).ok()?;
(!record.candidates.is_empty()).then_some(record)
}
/// 落盘结论 → 给模型的 [`CrashDiagnosis`]。
///
/// `exit` 仍取自索引条目(launch 报的事实,与盘上那份结论的计算输入同源),
/// `evidence` 仍是**脱敏**的 stderr 尾窗——隐私选择不变,只有结论换了来源。
fn into_diagnosis(self, entry: &SessionIndexEntry, evidence: Vec<String>) -> CrashDiagnosis {
CrashDiagnosis {
session_id: entry.session_id.clone(),
source: self.source.unwrap_or_else(|| "rules".to_string()),
needs_more_info: self.needs_more_info,
candidates: self
.candidates
.into_iter()
.map(StoredCandidate::into_candidate)
.collect(),
secondary: self
.secondary
.into_iter()
.map(StoredCandidate::into_candidate)
.collect(),
evidence,
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
}
}
}
impl StoredCandidate {
fn into_candidate(self) -> DiagnosisCandidate {
DiagnosisCandidate {
id: self.id,
severity: self.severity,
category: self.category,
confidence: self.confidence,
message_key: self.message_key,
message: self.message,
detail: self.detail.unwrap_or_default(),
evidence: self.evidence,
fix_actions: self
.fix_actions
.into_iter()
.filter_map(StoredAction::into_action)
.collect(),
}
}
}
impl StoredAction {
/// 未知维度返回 `None`:宁可少一条建议,也不瞎猜成别的维度(六维口径在 core 一处)。
fn into_action(self) -> Option<FixAction> {
let kind = match self.kind.as_str() {
"change_java" => FixActionKind::ChangeJava,
"adjust_memory" => FixActionKind::AdjustMemory,
"change_graphics" => FixActionKind::ChangeGraphics,
"remove_mod" => FixActionKind::RemoveMod,
"reinstall" => FixActionKind::Reinstall,
"seek_help" => FixActionKind::SeekHelp,
_ => return None,
};
Some(FixAction {
kind,
tool: self.tool,
label: String::new(),
args: self.args,
})
}
}
impl DiagnosePort for CoreDiagnose {
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
let entry = self.source.find(session_id)?;
let events = self.source.events(&entry)?;
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
// 会话级证据片段:仍是**脱敏**文本(隐私选择不变)。
let evidence: Vec<String> = stderr.iter().rev().take(EVIDENCE_LINES).cloned().collect();
// 盘上有结论就用它——那是写会话时在原始行上算的,不是这份脱敏文本能重算出来的。
if let Some(record) = StoredRecord::find(&events) {
return Ok(record.into_diagnosis(&entry, evidence));
}
// 老会话(M241 之前的 JSONL):回落到在脱敏文本上重跑。
let exit = ExitSignal::classify(
entry.exit_code,
Some(entry.duration_ms),
@@ -114,7 +285,7 @@ impl DiagnosePort for CoreDiagnose {
needs_more_info: report.needs_more_info(),
candidates: report.findings.iter().map(candidate_of).collect(),
secondary: report.secondary.iter().map(candidate_of).collect(),
evidence: stderr.iter().rev().take(3).cloned().collect(),
evidence,
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
@@ -123,76 +294,3 @@ impl DiagnosePort for CoreDiagnose {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use unlucky_core::launch::session_index;
/// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/<id>.jsonl`。
fn seed(root: &std::path::Path, stderr_line: &str) {
let sid = "20261029T124501-9f3c2a";
let log_path = session_index::log_path_for(sid);
let index = session_index::index_path(root);
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
let entry = json!({
"v": 1,
"session_id": sid,
"instance_id": "inst-1",
"instance_name": "1.20.1 Forge",
"game_version": "1.20.1",
"started_at": "2026-10-29T12:45:01.123Z",
"ended_at": "2026-10-29T12:45:05.123Z",
"duration_ms": 4000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 1,
"log_path": log_path.clone(),
"crash_found": true,
"diagnosis_top": null
});
std::fs::write(index, format!("{entry}\n")).expect("写索引");
let event = json!({
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
"kind": "stderr",
"payload": { "line": stderr_line, "level": "error" }
});
std::fs::write(
session_index::event_file(root, &log_path),
format!("{event}\n"),
)
.expect("写事件");
}
#[test]
fn runs_the_rule_engine_over_a_session() {
let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
seed(
&root,
"java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher",
);
let port = CoreDiagnose::new(root.clone());
let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断");
assert_eq!(report.session_id, "20261029T124501-9f3c2a");
assert_eq!(report.source, "rules");
assert_eq!(report.exit.code, Some(1));
// 规则集里应有 JVM/完整性类结论命中这条 stderr
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unknown_session_is_a_readable_error() {
let root =
std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id()));
let port = CoreDiagnose::new(root);
let err = port.diagnose("nope").unwrap_err();
assert!(err.contains("找不到会话"), "{err}");
}
}