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:
1 parent
d5a6f5dc50
commit
1a06163407
5 files changed
+840
-156
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user