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 预算
|
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算
|
||||||
//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。
|
//! 与结论质量的双重考虑,也是 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 std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
use unlucky_agent::port::{
|
use unlucky_agent::port::{
|
||||||
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind,
|
||||||
PortResult,
|
PortResult,
|
||||||
@@ -13,12 +29,20 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
|||||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
|
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding};
|
||||||
use unlucky_core::diagnose::loader::RuleLoader;
|
use unlucky_core::diagnose::loader::RuleLoader;
|
||||||
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
|
use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind;
|
||||||
|
use unlucky_core::launch::session_index::SessionIndexEntry;
|
||||||
|
|
||||||
use super::events::{tail_join, SessionSource};
|
use super::events::{tail_join, SessionSource};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "diagnose/tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
|
/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。
|
||||||
pub const DEFAULT_TAIL: usize = 2000;
|
pub const DEFAULT_TAIL: usize = 2000;
|
||||||
|
|
||||||
|
/// 会话级证据片段条数(与重跑路径同值:`stderr` 末尾 3 行)。
|
||||||
|
const EVIDENCE_LINES: usize = 3;
|
||||||
|
|
||||||
/// M15 规则引擎的端口实现。
|
/// M15 规则引擎的端口实现。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CoreDiagnose {
|
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 {
|
impl DiagnosePort for CoreDiagnose {
|
||||||
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
fn diagnose(&self, session_id: &str) -> PortResult<CrashDiagnosis> {
|
||||||
let entry = self.source.find(session_id)?;
|
let entry = self.source.find(session_id)?;
|
||||||
let events = self.source.events(&entry)?;
|
let events = self.source.events(&entry)?;
|
||||||
let (stdout, stderr, error_lines) = SessionSource::split_text(&events);
|
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(
|
let exit = ExitSignal::classify(
|
||||||
entry.exit_code,
|
entry.exit_code,
|
||||||
Some(entry.duration_ms),
|
Some(entry.duration_ms),
|
||||||
@@ -114,7 +285,7 @@ impl DiagnosePort for CoreDiagnose {
|
|||||||
needs_more_info: report.needs_more_info(),
|
needs_more_info: report.needs_more_info(),
|
||||||
candidates: report.findings.iter().map(candidate_of).collect(),
|
candidates: report.findings.iter().map(candidate_of).collect(),
|
||||||
secondary: report.secondary.iter().map(candidate_of).collect(),
|
secondary: report.secondary.iter().map(candidate_of).collect(),
|
||||||
evidence: stderr.iter().rev().take(3).cloned().collect(),
|
evidence,
|
||||||
exit: ExitSummary {
|
exit: ExitSummary {
|
||||||
code: entry.exit_code,
|
code: entry.exit_code,
|
||||||
exit_type: entry.exit_type.clone(),
|
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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! M242:AI 侧 `diagnose_crash` 的结论来源(落盘那份优先,老会话回落重跑)。
|
||||||
|
//!
|
||||||
|
//! 两条端到端用例都走**真链路**:真 `SessionWriter` 写盘 → 真工具注册(
|
||||||
|
//! `unlucky_agent::tools::diagnose::register`)→ 真 handler。判据是「命中哪条规则」,
|
||||||
|
//! 因为脱敏有损:在脱敏文本上重跑会**换一条**规则(`fabric.loader_0_12` →
|
||||||
|
//! `integrity.no_class_def_found`)。
|
||||||
|
//!
|
||||||
|
//! ## 夹具为什么把类路径登记成 secret
|
||||||
|
//!
|
||||||
|
//! M240 收窄了「`≥64` 不透明串」那条规则(路径形状的长串现在保留),所以 `SessionWriter`
|
||||||
|
//! 落盘时这条证据行今天**不会被抹**。要复现「读回脱敏文本就丢了证据」这个世界(M239 实测的
|
||||||
|
//! 老落盘形状),夹具把类路径登记成 secret:写盘前它变成 `<redacted:token>`,而结论是在
|
||||||
|
//! **原始行**上算的 —— 与用户盘上真实存在的那类会话同样失真,足以分辨两条路。
|
||||||
|
//!
|
||||||
|
//! 变异证明(改完已还原):
|
||||||
|
//! - 删掉 `diagnose()` 里的 `StoredRecord::find(&events)` 分支(等于每次都重跑)⇒
|
||||||
|
//! `ai_gets_the_stored_conclusion...` 红(`candidates[0].id` 变成 `integrity.no_class_def_found`)。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use unlucky_agent::port::DiagnosePort;
|
||||||
|
use unlucky_agent::registry::{ToolCtx, ToolRegistry};
|
||||||
|
use unlucky_agent::tools::diagnose::DIAGNOSE_CRASH;
|
||||||
|
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 super::CoreDiagnose;
|
||||||
|
|
||||||
|
/// `fabric.loader_0_12`(critical / priority 80)唯一的证据行(`rules.d/fabric.yaml`)里的类路径。
|
||||||
|
const CLASS_PATH: &str = "org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
|
||||||
|
/// 真凭据(写盘前必须被抹掉的形状)。
|
||||||
|
const TOKEN: &str = "SECRET-TOKEN-123456";
|
||||||
|
|
||||||
|
/// 那条完整的证据行。
|
||||||
|
fn evidence_line() -> String {
|
||||||
|
format!("java.lang.NoClassDefFoundError: {CLASS_PATH}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个临时数据根(`<root>` 当数据根,会话根就是 `<root>`——端口只认会话根这一层)。
|
||||||
|
fn temp_root(tag: &str) -> std::path::PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("ull-m242-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与生产写入同形(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("写事件");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一局真会话:`SessionWriter` 写盘(写前脱敏)→ `finish` 在原始行上补结论。
|
||||||
|
fn write_real_session(sessions_root: &Path) -> String {
|
||||||
|
let session_id = "20261102T091500-m242ai";
|
||||||
|
let mut writer = SessionWriter::create_with_id(
|
||||||
|
sessions_root,
|
||||||
|
session_id,
|
||||||
|
SessionMeta::new("inst-1", "1.20.1 Fabric", "1.20.1"),
|
||||||
|
// 类路径登记成 secret ⇒ 盘上那条证据行被抹掉(见模块文档:为什么要人为造这份失真)。
|
||||||
|
Redactor::new().with_secret(TOKEN).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, format!("auth: token={TOKEN}")),
|
||||||
|
];
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 走真工具入口调 `diagnose_crash`:注册表 + schema + handler 与 `ull agent serve` 同一份。
|
||||||
|
fn call_diagnose_crash(sessions_root: &Path, session_id: &str) -> Value {
|
||||||
|
let mut registry = ToolRegistry::new();
|
||||||
|
unlucky_agent::tools::diagnose::register(
|
||||||
|
&mut registry,
|
||||||
|
Arc::new(CoreDiagnose::new(sessions_root)),
|
||||||
|
)
|
||||||
|
.expect("注册 diagnose_crash");
|
||||||
|
let spec = registry.get(DIAGNOSE_CRASH).expect("工具已注册").clone();
|
||||||
|
let ctx = ToolCtx {
|
||||||
|
agent_session_id: "m242".to_string(),
|
||||||
|
confirmed: false,
|
||||||
|
};
|
||||||
|
let output =
|
||||||
|
(spec.handler)(&ctx, &json!({ "session_id": session_id })).expect("diagnose_crash 应成功");
|
||||||
|
output.content
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_gets_the_stored_conclusion_instead_of_a_rerun_on_redacted_text() {
|
||||||
|
let root = temp_root("ai-stored");
|
||||||
|
let session_id = write_real_session(&root);
|
||||||
|
|
||||||
|
let content = call_diagnose_crash(&root, &session_id);
|
||||||
|
assert_eq!(content["session_id"], session_id.as_str());
|
||||||
|
assert_eq!(
|
||||||
|
content["candidates"][0]["id"], "fabric.loader_0_12",
|
||||||
|
"落盘的结论必须原样交给模型(重跑会降级成 integrity.no_class_def_found):{content}"
|
||||||
|
);
|
||||||
|
assert_eq!(content["needs_more_info"], false);
|
||||||
|
let confidence = content["top_confidence"].as_f64().expect("置信度是数字");
|
||||||
|
assert!(
|
||||||
|
(confidence - 0.85).abs() < 1e-3,
|
||||||
|
"落盘结论的置信度应原样透传:{content}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 隐私选择不变:给模型的文本片段仍是脱敏的(类路径与凭据都不许出现)。
|
||||||
|
let rendered = content.to_string();
|
||||||
|
assert!(
|
||||||
|
!rendered.contains("FabricMixinTransformerProxy"),
|
||||||
|
"{rendered}"
|
||||||
|
);
|
||||||
|
assert!(!rendered.contains(TOKEN), "{rendered}");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runs_the_rule_engine_over_a_session() {
|
||||||
|
let root = temp_root("diagnose");
|
||||||
|
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));
|
||||||
|
// 老会话(没有 `diagnosis` 事件)→ 回落重跑:规则集里应有完整性类结论命中这条 stderr。
|
||||||
|
assert!(!report.candidates.is_empty(), "应至少命中一条规则");
|
||||||
|
assert_eq!(report.candidates[0].id, "integrity.no_class_def_found");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_session_is_a_readable_error() {
|
||||||
|
let root = temp_root("diagnose-none");
|
||||||
|
let port = CoreDiagnose::new(root);
|
||||||
|
let err = port.diagnose("nope").unwrap_err();
|
||||||
|
assert!(err.contains("找不到会话"), "{err}");
|
||||||
|
}
|
||||||
@@ -7,14 +7,19 @@
|
|||||||
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
|
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
|
||||||
//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。
|
//! 两个索引合并、事件流先新根后旧家),`launcher.log` 就在同一层的 `logs/` 里。
|
||||||
//!
|
//!
|
||||||
//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。
|
//! `diag` 的结论优先取会话里**落盘的那份**,老会话才把脱敏文本喂回 M15 的内嵌规则集
|
||||||
|
//! (M242,为什么与代价见 `sessions/diagnosis.rs` 的模块文档)。
|
||||||
|
|
||||||
|
mod diagnosis;
|
||||||
|
|
||||||
|
/// M242:`ull diag` 的结论来源(落盘那份优先)。拆文件守「单文件 ≤400 行」。
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "sessions/m242_tests.rs"]
|
||||||
|
mod m242_tests;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{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::{self, SessionIndexEntry};
|
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||||
|
|
||||||
use crate::cli::{DiagArgs, LogsArgs};
|
use crate::cli::{DiagArgs, LogsArgs};
|
||||||
@@ -118,7 +123,12 @@ pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result<Report, CliError> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `ull diag <sid> [--tail N]`:规则引擎诊断。
|
/// `ull diag <sid> [--tail N]`:给会话下诊断结论。
|
||||||
|
///
|
||||||
|
/// 结论来源**先是会话里落盘的 `diagnosis` 事件**(M242),没有它的老会话才在脱敏文本上重跑
|
||||||
|
/// ——为什么、代价如何见 `sessions/diagnosis.rs` 的模块文档。`--tail` 只影响重跑那条路。
|
||||||
|
///
|
||||||
|
/// `exit` 仍取自索引(launch 报的事实),不是 M15 的结论:换来源只换结论。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -127,74 +137,10 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
|||||||
let entry = find_entry(ctx, &args.session_id)?;
|
let entry = find_entry(ctx, &args.session_id)?;
|
||||||
let path = event_path(ctx, &entry);
|
let path = event_path(ctx, &entry);
|
||||||
let events = read_events(&path)?;
|
let events = read_events(&path)?;
|
||||||
|
let found = diagnosis::conclusion(&entry, &events, args.tail)?;
|
||||||
|
|
||||||
let mut stdout_lines: Vec<&str> = Vec::new();
|
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
|
||||||
let mut stderr_lines: Vec<&str> = Vec::new();
|
let human: Vec<String> = diagnosis::lines(&entry, &found)
|
||||||
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, args.tail);
|
|
||||||
let stderr_text = tail_join(&stderr_lines, args.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()))?;
|
|
||||||
|
|
||||||
let mut human = vec![format!(
|
|
||||||
"会话 {} · {} · {} · {}",
|
|
||||||
entry.session_id,
|
|
||||||
entry.instance_name,
|
|
||||||
entry.game_version,
|
|
||||||
exit_label(&entry)
|
|
||||||
)];
|
|
||||||
match report.primary() {
|
|
||||||
Some(finding) => {
|
|
||||||
human.push(format!("主因: {}", finding.message));
|
|
||||||
if let Some(detail) = &finding.detail {
|
|
||||||
human.push(format!("说明: {detail}"));
|
|
||||||
}
|
|
||||||
for evidence in finding.evidence.iter().take(3) {
|
|
||||||
human.push(format!("证据: {}", evidence.render()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()),
|
|
||||||
}
|
|
||||||
human.push(format!(
|
|
||||||
"规则评估 {} 条 · 结论 {} 条 · 次生 {} 条",
|
|
||||||
report.rules_evaluated,
|
|
||||||
report.findings.len(),
|
|
||||||
report.secondary.len()
|
|
||||||
));
|
|
||||||
let human: Vec<String> = human
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|line| unlucky_agent::redact::redact_text(line))
|
.map(|line| unlucky_agent::redact::redact_text(line))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -209,7 +155,8 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
|||||||
"silent_failure": entry.silent_failure,
|
"silent_failure": entry.silent_failure,
|
||||||
"duration_ms": entry.duration_ms,
|
"duration_ms": entry.duration_ms,
|
||||||
},
|
},
|
||||||
"diagnosis": record,
|
"conclusion_source": found.provenance.as_str(),
|
||||||
|
"diagnosis": found.record,
|
||||||
}));
|
}));
|
||||||
Ok(Report::new(payload, human))
|
Ok(Report::new(payload, human))
|
||||||
}
|
}
|
||||||
@@ -288,15 +235,6 @@ fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_event(event: &Value) -> String {
|
fn render_event(event: &Value) -> String {
|
||||||
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
|
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
|
||||||
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
|
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user