P0 实施报告 —— Incident 状态机 + 分层自适应调查引擎#
日期:2026-07-09 状态:已实现,
pytest472 passed + 真机验证通过 对应设计:docs/REDESIGN_CLOSEDLOOP_20260708.md(总纲)+docs/layers-v4/P0-incident-statemachine-and-adaptive-investigation.md(深挖) 本文按”做了什么 / 没做什么 / 为什么”三段写,已知缺口单列一节,不粉饰。
1. 一句话结论#
fast/deep 双模式已从代码里彻底删除。现在只有一条链路:告警 → L2 纯规则预处理 → Tier 1 → Tier 2 → Tier 3(命中即止,未命中自动递进并继承证据)→ 处置审批 → 三级验证门禁 → 报告。调查深度是链路的输出,不是调用方的输入。
真机验证(连的是本地 Postgres 5432 + Redis,不是 mock)#
| 验证项 | 结果 |
|---|---|
| DDL 平滑升级 | incidents 新增 5 列 + incident_transitions / runbooks 建表;遗留 open 行已迁移为 detected |
| 状态机 | detected → triaging → investigating → rca_ready 逐级落库 + 3 行审计日志;非法迁移 rca_ready --verify_passed--> 被拒 |
| L2 降噪 | 连推 5 条同 fingerprint 告警 → 只有 1 条进入调查链路,降噪率 80%,其余 4 条从未触碰 LLM |
| Runbook 飞轮刹车 | source=auto 草稿落 pending_review 时不参与 Tier 1 匹配;审核通过后才进 active 列表 |
| 卡审批不僵死 | 处置 interrupt 时 Incident 仍推进到 rca_ready(否则会僵在 investigating,见 §5 的坑) |
| 处置侧状态机 | committed+门禁过 → resolved;committed+门禁挂 → escalated;rolled_back → escalated;CAS 漂移 → escalated |
E2E 图冒烟(LLM/subagent 打桩)跑通两条路径:未知故障(Tier1 miss → Tier2 miss → Tier3 confirmed,出完整假设树报告)与 L2 抑制短路(不进任何 Tier,直接出降噪说明)。
2. 新增模块#
| 模块 | 职责 | 关键文件 |
|---|---|---|
app/incidents/state_machine.py | 8 态生命周期 + 迁移矩阵(默认拒绝)+ 拉回上限 | 纯函数,零 DB 依赖,可单测 |
app/preprocessing/ | L2 纯规则:去重/抑制/时间窗聚合/抖动/拓扑/Impact | rules.py topology.py(适配层) impact.py engine.py store.py |
app/topology/ | 资源图 + 级联关联(SCC 缩点 / 多跳上溯 / 因果时序门 / 静默根因) | graph.py correlate.py resolver.py registry.py store.py providers/ discovery/ |
app/runbooks/ | Tier 1 剧本库 + 匹配 + 冷却 + 自增长 + 审核 | models/registry/matcher/generator/prompts/reviewer.py |
app/investigation/ | L3 三级递进引擎 + 唯一诊断图 | engine.py graph.py tier1/2/3.py hypothesis.py retriever.py state.py |
app/api/v1/runbooks.py | 自增长闭环的出口(审核队列 API) | — |
data/runbooks/*.yaml | 5 个种子 Runbook(container/service/process/deployment) | 冷启动 |
data/topology_rules.yaml | 静态拓扑声明:主机/分区/外部依赖(默认关闭) | 服务间调用交给 compose/nginx provider 自动解析 |
scripts/discover_topology.py | 拓扑自动发现 CLI:--sniff / --mine / --expire / 审核队列 | 产出一律 pending_review |
3. 删除的东西(doc 明确要求)#
| 删除 | 理由 |
|---|---|
app/agents/graph.py(fast 图装配) | fast/deep 双模式归零 |
app/agents/planner.py / replanner.py | Plan-Execute-Replan 随 fast 图一起删 |
app/agents/remediation_fast.py | fast 图专属处置衔接节点 |
app/runtime/escalation.py(FastRunSignals / should_escalate_to_deep) | Tier 递进是链路内部行为,不再有”升级”概念 |
DiagnosisMode 枚举 + 全链路 diagnosis_mode 参数 | 调用方不再选模式 |
settings.deep_diagnosis_enabled / fast_first_escalation_enabled / fast_escalation_min_skill_confidence / inspection_mode / fast_remediation_plan_enabled | 同上 |
tests/test_fast_escalation.py / tests/test_fast_remediation.py | 被测对象已删除 |
scripts/eval_escalation_tradeoff.py | 度量的是 fast→deep 升级权衡,概念已消失 |
scripts/eval_fast_tool_scoping.py | 见 §6 缺口 ①(这条要留意) |
4. 保留并”重新安家”的东西#
skills/+skill_router:doc §3.1 明确”Skill 与 Runbook 并存,不替换”。现在的活口是 Tier 3 假设生成前选一本排障剧本注入 prompt(tier3._select_playbook)——Skill 的自由 Markdown playbook 恰好是”人怎么排查这类故障”的经验,正是假设生成需要的东西。fault_domain.is_cross_domain:原先用来”跨域 → 直达 deep”。V4 没有模式可跳,它改成 L2 Impact 的blast_radius因子(跨域 = 波及面按定义就大 → 抬高各 Tier 置信门槛)。同理alert_storm_threshold。- deep 图的处置后段节点(
remediation_planner → guard → [interrupt 等审批] → apply_decision → verify):这条链路已经真机跑通(飞书一键审批 + 超时降级 + CAS 漂移检测 + 补偿回滚),本次重构一行没动,主图直接 import 复用。app/diagnosis_graphs/build_deep_graph仅供评测脚本回放,不在生产路径。
4.5 实现过程中发现并修掉的一个真 bug#
处置命中人工审批(interrupt())时,tier_completed 事件永远不会发出来。
我最初把 INVESTIGATING → RCA_READY 的状态机迁移挂在 tier_completed 上——那是 runner 在图跑完之后才发的事件。可一旦处置卡在审批 interrupt,astream 提前返回,这个事件根本不会出现,Incident 会永远僵在 INVESTIGATING,处置链路接管时找不到合法的前驱状态。
修法:把 RCA_CONFIRMED 迁移挂到 rca 事件(由 tier 节点在图内发出,interrupt 之前一定已经发过),并让 runner 在 escalate=True 时不发 rca 事件——那是个 BEST_EFFORT 的”最像的猜测”,不是确认的根因,绝不能据此推进 RCA_READY。escalate 走 tier_completed → RCA_INSUFFICIENT。
另外补了 _ensure_investigating():手动诊断 / 聊天升级 / 定时巡检建的 Incident 停在 DETECTED,不先推一步的话后续 RCA_CONFIRMED 就是非法迁移。
4.6 处置侧状态机(P0 §1.2 的后半张表)#
app/remediation_worker.py 现在把执行结果翻译成 Incident 迁移:
审批通过 (claim_for_execution) → AUTONOMY_APPROVAL → REMEDIATING
committed + 三级门禁通过 → EXECUTION_DONE → VERIFY_PASSED → RESOLVED
committed + 门禁未过 → EXECUTION_DONE → VERIFY_FAILED → ESCALATED
rolled_back (已自动回滚) → EXECUTION_DONE → VERIFY_FAILED → ESCALATED
state_drift/guard_rejected/... → EXECUTION_FAILED → ESCALATEDplaintext“resolved ≠ 根治”这句话的唯一落点就在这里:只有三级门禁真的过了才允许 RESOLVED;回滚成功只说明处置流程妥善收尾,故障并没有解决,照样交人工。
同时闭合了 L6 防污染的最后一环:Tier 1 的提案携带 runbook_id(新增 approval_requests.runbook_id 列)流到 worker,门禁没过就自动把那份 Runbook 打回 pending_review —— 一份会把事情做错的剧本,在人工看它之前不该再命中第二次。
5. 三级验证门禁(P0 §4 的裁决落地)#
新增第一级 command_success(此前”隐含在事务引擎里”,没有显式门禁对象,报告看不到、统计不到、Runbook 里也声明不了):
- 判失败:
apply步 ok=False,或post_state.state ≠ effect(命令返回 0 但什么也没发生,这正是这一级要抓的); - 判 inconclusive(不失败):journal 无 apply 步、
post_state.predicted=True(探针不可用时的引擎预测值)——拿不到证据 ≠ 证据说它失败了; - 统一入口
build_standard_gates(),STANDARD_GATE_ORDER = (command_success, service_health, metric_recovery)。Runbook 的verify_steps与 closed_loop 的回归验证共用同一份实现,否则”跑完 ≠ 解决”只是口号。 - baseline_comparison / impact_assessment 仍是 P2 backlog。
6. 已知缺口(诚实清单,明早请重点看这一节)#
① eval_fast_tool_scoping.py 已删除,对应的工具收窄度量失去测量脚本。
它度量的是 fast Executor 的 filter_tools_for_skill(“每轮可用工具 30+ → 7-9 个”)。fast 图删除后,工具收窄在 V4 里由 Tier 3 各专业 subagent 的硬编码工具白名单承担(metric_agent._load_metric_tools 等),不再经过 filter_tools_for_skill。
→ 后果:需要给 Tier 3 subagent 补一个等价的工具可见性 benchmark。
② Tier 2 的检索不是 pgvector + pg_search(BM25) + RRF。 ✅ 已闭合(2026-07-09)
Incident 现在会向量化落库:新表 incident_vectors(app/incidents/vector_index.py),HNSW/cosine + pg_search BM25 双索引,写入时机是 IncidentRepository.transition() 迁到 RESOLVED 之后(事务外 fire-and-forget,索引失败不影响状态机)。存量回填走 scripts/reindex_incidents.py。
HybridSimilarIncidentRetriever 用与知识库同一份 RRF 实现 —— 为此把 hybrid_retriever.py 里的 RRF 抽成了通用纯函数 rrf_fuse(ranked_lists, key_fn, k),hybrid_search 改为调它,两处不再各写一遍。
两个设计要点:
- RRF 只管排序,不当相似度用。
tier2_similarity_threshold的语义是”像不像”,而 RRF 分数没有 [0,1] 量纲。门槛用向量 cosine;BM25-only 命中(向量路没召回)回落字面相似度,同样 [0,1] 且更保守。融合时向量路排在前面,rrf_fuse对同一 key 保留首次出现的对象,于是两路都命中时留下的是带 cosine 的那个。 - 负样本闭环接活了。
Tier2Result.negative_samples此前是收集了但无人消费的死字段。现在 Tier 2 的复核结论回写incident_vectors.negative_count/positive_count,检索时按sim / (1 + w·n)乘性降权(tier2_negative_penalty,默认 0.3),误命中过的候选既排得更后也更难过门槛。 三档降级仍在:embedding 挂 → 该行只进 BM25;没装 pg_search → 退纯向量;两路都空 → 回落字面相似 + Wiki。所以tier2_retriever=hybrid(默认)在语料表还是空表时行为与 Day 1 完全一致,不需要手动切。
③ Tier 1 在没有 MCP 只读工具的环境里命中率恒为 0。
前置条件校验要求资源当前态 ∈ 该操作的 preconditions;探测不到状态时判不通过(宁可递进 Tier 2,也不瞎跑剧本处置生产环境)。这是刻意的保守设计,但意味着”Tier 1+2 消化率 > 60%“这个目标必须在 MCP 工具连上的环境里测。
2026-07-09 修:
_snapshot_resource_state此前只认dict返回值,而 FastMCP 会把 dict 序列化成 JSON 文本 block 再经 LangChain 回来。于是所有返回 dict 的探针(deployment_status/service_status/ 新增的imagestore_usage)实际拿到的 state 恒为空,对应 Runbook 永远 miss 且不报错。现在会先试着把文本解回 dict。
④ 磁盘清理没有种子 Runbook。 ✅ 已闭合(2026-07-09),但没有引入 disk.clean。
通用的 disk.clean(path) 一旦存在,LLM 就能把任意路径塞进参数,等价于把 rm -rf 交还给它 —— 白名单工具就白做了。所以新增的是一个粒度写死的资源类型 imagestore(app/remediation/model.py):
- 资源 URI
imagestore://docker/<scope>,回收范围写在 URI 里做闭集校验,只有dangling_images/stopped_containers/build_cache三个值。volumes、all、任意路径一律ValueError。写操作imagestore.prune不接受任何 params(scope 只能来自 URI,否则调用方就能绕开 URI 校验)。 - prune 的 argv 是常量表,没有字符串拼接,也就没有注入面。绝不带
-a、绝不--volumes、绝不走docker system prune。 - 状态机是关键:
reclaimable(该范围实际可回收字节 ≥ 阈值)/clean。于是”没什么可清”天然是前置条件失败 → 自动递进 Tier 2,不会空跑一次不可回滚的处置;审批期间垃圾被别的进程清掉 → CAS 检出reclaimable → clean漂移 → 零变更中止。 - 不可回滚是如实建模,不是妥协:删掉的镜像层回不来,磁盘已满时”先备份再删”物理上也不成立。所以
imagestore.prune是 IRREVERSIBLE,三份种子 Runbook(data/runbooks/seed_disk.yaml)都没有rollback_steps→reversible=False→ 按 §5 D4 自动降级人工审批。Tier 1 在这里贡献的是零 LLM 的确定性识别与提案,不是无人值守的自动执行。 imagestore_prune已登记进app/tools/meta.py且restricted=True。这不是可选项:RESTRICTED_TOOLS从TOOL_META推导,未登记的工具走保守默认restricted=False,tool_filter就不会把它剔出诊断工具集,Tier 3 的 subagent 能直接调它删镜像。登记后诊断阶段一律 deny(连 BYPASS 直通都挡),只有 remediation 执行上下文才放行到审批。只读的imagestore_usage不 restricted —— 否则 Tier 1 前置校验探不到状态。- 覆盖率的代价照付:这三份 Runbook 只能救”docker 垃圾把盘吃满”,救不了”某个业务日志目录涨爆了”,后者一路落到 Tier 3 由人来看。宁可 Tier 1 覆盖率低,不为覆盖率退回自由命令。
顺带修了 Tier 1 的一个既有局限:候选 Runbook 前置条件不过就直接判 miss,不会试下一个。一条”磁盘满”告警会同时匹配三份 Runbook,“悬空镜像没得清”不代表”构建缓存也没得清” —— 卡在第一个候选上会白白放过能自愈的那份。现在逐个候选试,命中第一个真正有东西可回收的。(真机验证:前两份因 clean 前置失败,第三份 build_cache 3.86GB 通过。)
三个 docker JSON 的坑,踩过并已钉进测试(tests/test_imagestore_reclaim.py):
docker system df -v把布尔序列化成字符串'false',not x.get('InUse')恒为 False → build_cache 可回收量恒 0,Runbook 永不命中且不报错;- 时间串有三种格式(带时区秒 / 纳秒 UTC / 无时区),解析错会让年龄过滤失效;
docker builder prune --filter until=过滤的是未使用时长(LastUsedAt),不是创建时间;用CreatedAt会把”上周建、今早刚用过”的缓存算进可回收量,而 prune 不删它 → verify 永远假失败。- 同理,顶层
docker system df的Images.Reclaimable含有 tag 但未被容器使用的镜像,而image prune(不带-a)根本不删它们。真机实测:顶层报 13.04GB,实际 dangling 只有 0.907GB,差 14 倍。可回收量的口径必须与 prune 的过滤条件一一对应。
⑤ 拓扑关联默认关闭,Day 1 只有静态 YAML。 ✅ 已重做(2026-07-09,新增 app/topology/)
原实现有一个 bug 级的硬伤和三个建模缺陷。bug:active_services 只取同一个 webhook POST 里的告警(webhook.py),但 Alertmanager 按 group_by 分批投递,上游(mysql)与下游(web-api)的告警几乎必然落在两个 POST 里 —— 级联在真实流量下从来没有命中过。现在改从 PreprocessStore.firing_services() 这个跨请求的窗口视图读,并带每个服务的最早告警时间(因果时序判定要用)。
三个建模缺陷及其修法:
- 只有
depends_on一种边,是把 K8s/微服务的世界观直接搬过来了。小企业 90% 的级联是垂直的(磁盘写满 → MySQL 拒写 → 业务 502)。现在是 typed 资源图:depends_on(调用)+runs_on(承载),方向统一指向”因”,于是同一套遍历既能上溯调用链又能上溯承载链。same_node这种”共因”关系被物化成合成主机节点(providers/static_yaml.py)——“共用一台主机”的准确表达就是”都 runs_on 这台主机”。 - 根因节点常常自己不告警(那块盘没配监控)。旧算法只在”正在告警的节点”里挑根因,这类故障必然挑错。新增静默根因判定:在残差集(互相独立的直接根因)上找共同祖先,两个指标同时达标才成立 ——
explains = |残差∩辖区|/|残差|(它解释了多少个本该互相独立的根因)、precision = |告警∩辖区|/|辖区|(它的辖区烧得有多彻底)。两个分子刻意不同口径:只有 explains 会让”整个机房”永远满分,只有 precision 会让任何叶子的父节点都是 1.0。残差集是关键 —— 用全部告警的话,“根因所在的那台主机”永远抢走根因位(它的辖区当然全在烧,但那是根因烧的)。 - 一跳。现在是 Tarjan SCC 缩点(环里分不出先后,报成一个整体)+ 多跳反向可达 + 置信度衰减(
Π边置信度 × decay^跳数,最大乘积路径用 Dijkstra 式最优先搜索)+ 因果时序门(上游晚于下游超过causality_skew_sec就不认它是因 —— “MySQL 在 web-api 挂了三分钟后才报警”,它多半是被打死的受害者)。同分时层级低的赢:基础设施 > 中间件 > 应用 > 业务指标。
还有一个隐性的、实战里占 80% 工作量的东西:身份解析(resolver.py)。同一个 MySQL 在不同告警里可能是 service=mysql-master / instance=10.0.0.5:9104 / container_name=prod-mysql-1。解析不到就返回 None,让该告警退化成”自己是根因”,绝不猜 —— 猜错的拓扑归因比没有拓扑归因更有害。
拓扑数据不再靠人手写(topology_sources,按性价比排序):① compose / nginx provider 从既有配置解析(depends_on/links/DB_HOST 环境变量/volumes→宿主机分区/upstream),零人工;② sniff 读 ss -tnp 连接表自动发现,无侵入;③ mined 从告警历史挖共现(同时看条件概率和 lift —— 只看前者会被”B 本来就一直在告警”骗过去);④ 手写 YAML 降级为”补充与覆盖”,只声明推不出来的东西(主机/分区/DNS/证书/第三方 API)。自动发现的边落 topology_edges 表,带 observations/last_seen_at/TTL —— 服务下线后边会自己过期消失,静态拓扑腐烂的问题由此自愈。
降噪的闸门(会吞告警,三道锁):topology_cascade_suppress 默认关闭;归因链上每条边必须 status=active(人审过);静默根因永不触发抑制(没人会去调查一个没有告警的节点,把它的受害者全吞了等于让整场故障静默)。自动发现的边一律 pending_review 落库,只参与根因打分 —— 与 Runbook 自增长飞轮同一个刹车。
真机验证(本地 Postgres):
topology_edgesDDL、upsert 幂等(observations累加、置信度只升不降)、人审的active不会被下一轮嗅探降级回pending_review、TTL 过期只删 pending 不删 active、refresh_graph()跨 provider 别名解析(10.0.0.5:9104→service:mysql-master)与悬挂边丢弃,全部跑通。 单测:tests/test_topology_correlation.py(17) /test_topology_providers.py(15) /test_topology_discovery.py(12)。 仍是 backlog:K8s API / OTel Service Graph provider(接口就是providers/*.py那个形状,链路不变);pending边的审核队列目前只有 CLI(scripts/discover_topology.py --list/--approve),没有前端。
⑨ Runbook 自增长闭环的提炼环节从未被触发。 ✅ 已闭合(2026-07-09)
generate_runbook() 此前零生产调用方(只有 tests 在调),rca_accepted / verification_passed 两个参数在 app/ 里除了函数签名再无第二处出现,也没有任何”人工确认 RCA”的入口。后果是:Tier 3 查明未知故障、门禁全过、Incident 走到 RESOLVED 之后什么也不会发生,/runbooks/pending 永远返回空,Tier 1 覆盖率永远只有手写种子那几份 —— 飞轮装好了,没人踩第一脚。
补上的是那一脚:
- 新表
investigations(app/investigation/repository.py)。提炼需要当时那次调查的完整假设树 + 证据链 + 根因,而这些此前只活在 LangGraph 的内存 state 里,图一跑完就没了。audit.py消费 runner 新发的investigation事件落库;一次诊断恰好落一行(只有最终停下的那一级会返回investigation)。Tier 1/2 的结论也落 —— 它们提炼不出 Runbook,但”停在哪一级、当时看到了什么”是可审计的事实。 - 两个触发点(
app/runbooks/growth.py::maybe_generate_runbook):POST /incidents/{id}/accept-rca(人工确认)与 remediation_worker 推 VERIFY_PASSED 之后(门禁通过)。两者到达顺序不确定(人可能在门禁跑完前就点了采纳),所以不能”谁先到谁负责”,只能各调一次、各自检查三条件是否已全中,靠investigations.generated_runbook_id做幂等 —— 谁最后到,谁提炼。 - 判据不重复造:
verification_passed的唯一依据是 Incident 已被状态机推到 RESOLVED,不另读 regression 结果。“resolved ≠ 跑完”这条规则的裁决者只有_advance_after_execution,在别处再写一份迟早漂移。 - 刹车全在:草稿仍是
source=auto+status=pending_review,validate_draft仍拿资源域 REGISTRY 拒收 LLM 发明的动作。GenerationRefused是正常路径不是异常,一律吞掉 —— 飞轮转不动不该影响处置执行或 HTTP 响应。
真机验证:
investigations表 DDL + 仓储 SQL(含mark_rca_accepted的子查询 UPDATE)+ 三条件 + 幂等已在本地 Postgres 跑通;单测见tests/test_runbook_growth.py。
⑩ “未命中自动递进并继承证据”里的证据继承是空管道。 ✅ 已闭合(2026-07-09)
此前有两层脱节:(a) 做了证据继承的 engine.py::run_ladder 不在生产路径上(调用方只有 tests 和 __init__ 导出),线上跑的 graph.py 里 tier2/tier3 节点硬编码 inherited_evidence=[];(b) 就算修好也继承不到东西 —— Tier 1/2 压根不产 Evidence,evidence_ids 的赋值全项目只出现在 tier3.py 一处,而那里因为 subagent 的 Evidence 没有 id 字段,[ev["id"] for ev in ...] 过滤下来也恒为空。跨级真正传递的只有 excluded_directions(排除方向)。
现在:
- Tier 1 产
resource_state_probe证据:前置条件校验本来就对目标资源做了一轮只读探测,未命中时这些观测(容器还在 running / 镜像没什么可回收)恰恰是 Tier 3 生成假设时最想要的事实。丢掉它等于让 Tier 3 再派一次 subagent 去问同一个问题。 - Tier 2 产
similar_incident证据(含被判not_applicable的),并把 Tier 1 的证据注入适用性复核的可信上下文段(与 UNTRUSTED 的历史报告分列)——历史案例说”容器 OOM 被 kill”而 Tier 1 刚探到容器仍 running,这条根因就该判不适用。 - Tier 3 的假设生成拿到前两级的证据当既定事实(“不要提出与之矛盾的假设,也不要重复取证”),判定每条假设时继承证据与本轮定向取证一起呈给 LLM,报告里两者分段渲染。
- 顺带修掉
investigation.evidence_ids/hypothesis.evidence_chain恒空的 bug:给 subagent 的 Evidence 补本地引用 id(它们尚未落库,没有 DB id)。 engine.py顶部已注明它不是生产路径,两处的 Tier 顺序 / 命中即止 / 证据继承必须保持一致;tests/test_evidence_inheritance.py同时覆盖 LangGraph 节点与纯阶梯两条路径。
⑥ §12 的指标一个都还没实测。 降噪率 / Tier 命中分布 / token 节省 / 飞轮前移率的采集埋点都已就位(suppressed/dedup_count/investigation_tier/tier_trace 都落库),但没有跑过告警风暴回放。基线数字要用 scripts/loadtest.py + scripts/eval_dedup_layers.py 实测后再写。
⑦ docs/diagnosis-modes.html 仍描述已删除的 fast/deep,需要重写或标注为历史文档(已在 CLAUDE.md 里标注为历史留存)。
⑧ “命中后 verify 失败的 Runbook 自动标记待审查”只做了一半 ✅ 已闭合:给 approval_requests 加了 runbook_id 列,Tier 1 的提案带着它一路流到 worker;门禁没过就自动 flag_for_review() 打回 pending_review(并记进 hit_count/success_count 分母)。LLM 规划的处置没有 runbook_id,不会被误记。
7. 数据库变更(老库平滑升级,全部 IF NOT EXISTS / 幂等 UPDATE)#
-- incidents: 5 个新列 + 状态值迁移 + 默认值改 detected
ALTER TABLE incidents ADD COLUMN IF NOT EXISTS investigation_tier / investigation_id
/ rca_confidence / resolved_count / escalation_reason;
UPDATE incidents SET status = 'detected' WHERE status = 'open'; -- 及 mitigated/closed/suppressed
-- 新表
CREATE TABLE incident_transitions (...); -- 状态迁移审计
CREATE TABLE runbooks (...); -- 自增长产物 + 人工创建
CREATE TABLE incident_vectors (...); -- Tier 2 检索语料 (HNSW/cosine + pg_search BM25)
-- diagnosis_tasks
ALTER TABLE diagnosis_tasks ADD COLUMN IF NOT EXISTS investigation_tier;
ALTER TABLE diagnosis_tasks ALTER COLUMN diagnosis_mode DROP NOT NULL; -- 列保留停写sqlincident_vectors 建表在 app/incidents/vector_index.py::init_incident_vector_schema(),API 与 diagnosis worker 启动时各调一次(advisory lock 990003 串行化 DDL)。建表失败不阻塞启动 —— 检索侧会回落字面相似。embedding 列可空:embedding provider 挂掉时该行仍进 BM25 索引,向量检索侧 WHERE embedding IS NOT NULL 跳过它。
app/incidents/state_machine.py::LEGACY_STATUS_MAP 兜住”DDL 跑之前就被读到的旧行”。
8. 测试#
| 新增测试 | 覆盖 |
|---|---|
tests/test_incident_state_machine.py (11) | 迁移矩阵默认拒绝、失败路径全收敛 ESCALATED、拉回上限、遗留状态归一 |
tests/test_preprocessing_rules.py (13) | 去重/状态变化不去重/抑制/自抑制护栏/抖动折减/拓扑指上游/impact 只调门槛 |
tests/test_runbooks.py (23) | 写不出 shell 命令、fullmatch 严格匹配、冷却、种子静态校验、自增长三条件、LLM 发明动作被拒收、草稿恒 pending_review |
tests/test_investigation_ladder.py (13) | 命中即止(Tier1 命中则 Tier2/3 一次不调)、限 3 轮、负样本、达标即终止、BEST_EFFORT 不硬编、impact 抬门槛 |
tests/test_regression_gates.py (+7) | command_success 三态(pass/fail/inconclusive)+ 三级顺序 + 缺探针跳过 |
tests/test_remediation_lifecycle.py (10) | 执行结果 → 状态机迁移映射;committed 但门禁挂 → ESCALATED;回滚 ≠ 解决;门禁失败的 Runbook 自动打回待审查 |
tests/test_diagnosis_runner_thread_id.py (重写) | 单图 thread_id 透传 + IncidentContext 透传 + diagnosis_mode 参数不存在的回归护栏 |
tests/test_tier2_hybrid_retrieval.py (16) | RRF 只管排序不当相似度用、两路命中保留带 cosine 的对象、负样本降权能把候选压到门槛下、三档降级链(embedding 挂 / BM25 挂 / 语料空) |
tests/test_imagestore_reclaim.py (24) | 回收范围是闭集(volumes/-a/路径穿越全拒)、prune 不接受 params、argv 无用户可控片段、recovery_plan 恒 None、docker JSON 的三个坑(字符串布尔 / 三种时间格式 / LastUsedAt vs CreatedAt)、探针解 JSON 文本 |
全量:472 passed。
9. 下一步(按 ROI)#
- 补 §6 ① 的工具可见性 benchmark;
- 跑一次告警风暴回放,实测降噪率 / Tier 命中分布(§12 的数字);
Incident 向量化落库 → Tier 2 换成真正的 pgvector + BM25 + RRF✅ 已完成(§6 ②)。尚未实测检索质量:incident_vectors需要积累到一定量级才有意义,且换 embedding 模型后要reindex_incidents.py --force重算。Tier 2 命中率 / 误命中率的基线要等风暴回放一起测;layers-v4/L4-decision-autonomy.md(自治分级矩阵 + 变更冻结窗口 + “无回滚即降级”规则的显式实现);layers-v4/security-defense-in-depth.md+ 命令安全闸(§5 D3,翻写 commandFilter)。