Files
Unlucky-Launcher/crates/cli/src/commands/agent/ports/diagnose.rs
T
AskaEth 1a06163407 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`),已还原。
2026-11-11 10:34:18 +08:00

297 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。
//!
//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 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,
};
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 {
source: SessionSource,
tail: usize,
}
impl CoreDiagnose {
/// `root` = `<数据根>/.ULL/logs`(M188 起;旧名是 `data/agent/sessions`)。
#[must_use]
pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
Self {
source: SessionSource::new(root),
tail: DEFAULT_TAIL,
}
}
/// 再挂一个只读兼容的旧根(`data/agent/sessions`):旧会话也能诊断。
#[must_use]
pub fn with_legacy(mut self, legacy: impl Into<std::path::PathBuf>) -> Self {
self.source = self.source.with_legacy(legacy);
self
}
}
fn map_kind(kind: CoreFixKind) -> FixActionKind {
match kind {
CoreFixKind::ChangeJava => FixActionKind::ChangeJava,
CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory,
CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics,
CoreFixKind::RemoveMod => FixActionKind::RemoveMod,
CoreFixKind::Reinstall => FixActionKind::Reinstall,
CoreFixKind::SeekHelp => FixActionKind::SeekHelp,
}
}
fn candidate_of(finding: &Finding) -> DiagnosisCandidate {
let core_candidate = finding.to_candidate();
DiagnosisCandidate {
id: core_candidate.id,
severity: core_candidate.severity.to_string(),
category: core_candidate.category.to_string(),
confidence: core_candidate.confidence,
message_key: core_candidate.message_key,
message: core_candidate.message,
detail: core_candidate.detail.unwrap_or_default(),
evidence: core_candidate.evidence,
fix_actions: core_candidate
.fix_actions
.into_iter()
.map(|action| FixAction {
kind: map_kind(action.kind),
tool: action.tool.to_string(),
label: String::new(),
args: action
.args
.into_iter()
.collect::<BTreeMap<String, String>>(),
})
.collect(),
}
}
// ─────────────────── 落盘的结论(`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),
error_lines,
&stderr.join("\n"),
);
let mut input = DiagnosisInput::new().with_exit(exit);
let stdout_text = tail_join(&stdout, self.tail);
if !stdout_text.is_empty() {
input.push_text(SignalOrigin::Stdout, stdout_text);
}
let stderr_text = tail_join(&stderr, self.tail);
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);
Ok(CrashDiagnosis {
session_id: entry.session_id.clone(),
source: "rules".to_string(),
needs_more_info: report.needs_more_info(),
candidates: report.findings.iter().map(candidate_of).collect(),
secondary: report.secondary.iter().map(candidate_of).collect(),
evidence,
exit: ExitSummary {
code: entry.exit_code,
exit_type: entry.exit_type.clone(),
silent_failure: entry.silent_failure,
},
})
}
}