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
@@ -7,14 +7,19 @@
|
||||
//! **M188 起 `sessions_root` = `<.ULL>/logs`**(旧家 `<.ULL>/data/agent/sessions` 只读兼容:
|
||||
//! 两个索引合并、事件流先新根后旧家),`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 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 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
|
||||
///
|
||||
@@ -127,74 +137,10 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
||||
let entry = find_entry(ctx, &args.session_id)?;
|
||||
let path = event_path(ctx, &entry);
|
||||
let events = read_events(&path)?;
|
||||
let found = diagnosis::conclusion(&entry, &events, args.tail)?;
|
||||
|
||||
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, 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
|
||||
// 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。
|
||||
let human: Vec<String> = diagnosis::lines(&entry, &found)
|
||||
.iter()
|
||||
.map(|line| unlucky_agent::redact::redact_text(line))
|
||||
.collect();
|
||||
@@ -209,7 +155,8 @@ pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result<Report, CliError> {
|
||||
"silent_failure": entry.silent_failure,
|
||||
"duration_ms": entry.duration_ms,
|
||||
},
|
||||
"diagnosis": record,
|
||||
"conclusion_source": found.provenance.as_str(),
|
||||
"diagnosis": found.record,
|
||||
}));
|
||||
Ok(Report::new(payload, human))
|
||||
}
|
||||
@@ -288,15 +235,6 @@ fn read_events(path: &Path) -> Result<Vec<Value>, CliError> {
|
||||
.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 {
|
||||
let ts = event.get("ts").and_then(Value::as_str).unwrap_or("");
|
||||
let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?");
|
||||
|
||||
Reference in new issue
Block a user