nthlayer-workers 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# src/nthlayer_respond/coordinator.py
|
|
2
|
+
"""Coordinator state machine — pure transport, no judgment.
|
|
3
|
+
|
|
4
|
+
Sequences the agent pipeline, persists context after each step,
|
|
5
|
+
handles crash recovery via last_completed_step_index, and gates
|
|
6
|
+
on escalation / human approval.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from nthlayer_workers.respond.types import (
|
|
17
|
+
AgentRole,
|
|
18
|
+
IncidentContext,
|
|
19
|
+
IncidentState,
|
|
20
|
+
TERMINAL_STATES,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
# Step 0: triage (serial)
|
|
26
|
+
# Step 1: investigation + communication (parallel)
|
|
27
|
+
# Step 2: remediation (serial)
|
|
28
|
+
# Step 3: communication resolution update (serial)
|
|
29
|
+
PIPELINE: list[list[AgentRole]] = [
|
|
30
|
+
[AgentRole.TRIAGE],
|
|
31
|
+
[AgentRole.INVESTIGATION, AgentRole.COMMUNICATION],
|
|
32
|
+
[AgentRole.REMEDIATION],
|
|
33
|
+
[AgentRole.COMMUNICATION],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Map from pipeline step index to the state the coordinator should set
|
|
37
|
+
# before running that step.
|
|
38
|
+
_STEP_STATES: dict[int, IncidentState] = {
|
|
39
|
+
0: IncidentState.TRIAGING,
|
|
40
|
+
1: IncidentState.INVESTIGATING,
|
|
41
|
+
2: IncidentState.REMEDIATING,
|
|
42
|
+
3: IncidentState.REMEDIATING, # still remediating phase for resolution update
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Coordinator:
|
|
47
|
+
"""Deterministic state machine that sequences agent execution.
|
|
48
|
+
|
|
49
|
+
Not an agent — has no model access. Pure transport: receives context,
|
|
50
|
+
runs the pipeline, persists state, checks gates.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
agents: dict[AgentRole, Any],
|
|
56
|
+
context_store: Any,
|
|
57
|
+
verdict_store: Any,
|
|
58
|
+
config: Any,
|
|
59
|
+
safe_action_registry: Any | None = None,
|
|
60
|
+
escalation_runner: Any | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._agents = agents
|
|
63
|
+
self._context_store = context_store
|
|
64
|
+
self._verdict_store = verdict_store
|
|
65
|
+
self._config = config
|
|
66
|
+
self._registry = safe_action_registry
|
|
67
|
+
self._escalation_runner = escalation_runner
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
# Public API #
|
|
71
|
+
# ------------------------------------------------------------------ #
|
|
72
|
+
|
|
73
|
+
async def run(self, context: IncidentContext) -> IncidentContext:
|
|
74
|
+
"""Execute the agent pipeline from the current step to completion.
|
|
75
|
+
|
|
76
|
+
On success: state -> RESOLVED.
|
|
77
|
+
On escalation gate: state -> ESCALATED.
|
|
78
|
+
On human approval gate: state -> AWAITING_APPROVAL.
|
|
79
|
+
On unrecoverable error: state -> FAILED.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
return await self._run_pipeline(context)
|
|
83
|
+
except Exception as exc: # noqa: BLE001
|
|
84
|
+
logger.error("coordinator_unrecoverable", error=str(exc))
|
|
85
|
+
context.state = IncidentState.FAILED
|
|
86
|
+
# P3-E.1: <reason>: <details> error format convention
|
|
87
|
+
context.error = f"unrecoverable: {exc}"
|
|
88
|
+
self._context_store.save(context)
|
|
89
|
+
return context
|
|
90
|
+
|
|
91
|
+
async def resume(self, incident_id: str) -> IncidentContext:
|
|
92
|
+
"""Load a persisted context and continue the pipeline."""
|
|
93
|
+
context = self._context_store.load(incident_id)
|
|
94
|
+
if context is None:
|
|
95
|
+
raise ValueError(f"Incident {incident_id!r} not found in context store")
|
|
96
|
+
return await self.run(context)
|
|
97
|
+
|
|
98
|
+
async def approve(self, incident_id: str, approved_by: str | None = None) -> IncidentContext:
|
|
99
|
+
"""Execute the approved safe action for a paused incident.
|
|
100
|
+
|
|
101
|
+
Requires state == AWAITING_APPROVAL.
|
|
102
|
+
On success: state -> RESOLVED.
|
|
103
|
+
On failure: state -> ESCALATED.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
incident_id: Incident to approve.
|
|
107
|
+
approved_by: Identity of the approver (e.g. email). Stored in
|
|
108
|
+
verdict metadata and reasoning for auditability. Defaults to
|
|
109
|
+
"human" when not provided.
|
|
110
|
+
"""
|
|
111
|
+
context = self._context_store.load(incident_id)
|
|
112
|
+
if context is None:
|
|
113
|
+
raise ValueError(f"Incident {incident_id!r} not found in context store")
|
|
114
|
+
if context.state != IncidentState.AWAITING_APPROVAL:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Incident {incident_id!r} is in state {context.state.value}, "
|
|
117
|
+
f"not AWAITING_APPROVAL"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
remediation = context.remediation
|
|
121
|
+
if remediation is None:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Incident {incident_id!r} has no remediation result to approve"
|
|
124
|
+
)
|
|
125
|
+
action = remediation.proposed_action
|
|
126
|
+
target = remediation.target
|
|
127
|
+
|
|
128
|
+
if self._registry is None:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Incident {incident_id!r}: no safe action registry configured on coordinator"
|
|
131
|
+
)
|
|
132
|
+
registry = self._registry
|
|
133
|
+
|
|
134
|
+
who = approved_by or "human"
|
|
135
|
+
from nthlayer_common.verdicts import create as verdict_create
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
exec_result = await registry.execute(action, target, context)
|
|
139
|
+
remediation.executed = True
|
|
140
|
+
remediation.execution_result = exec_result.get("detail", "")
|
|
141
|
+
|
|
142
|
+
v = verdict_create(
|
|
143
|
+
subject={
|
|
144
|
+
"type": "remediation",
|
|
145
|
+
"ref": context.id,
|
|
146
|
+
"summary": f"approved: {action} on {target}",
|
|
147
|
+
},
|
|
148
|
+
judgment={
|
|
149
|
+
"action": "approve",
|
|
150
|
+
"confidence": 1.0,
|
|
151
|
+
"reasoning": f"{who} approved {action} on {target}",
|
|
152
|
+
},
|
|
153
|
+
producer={"system": "nthlayer-respond", "instance": "coordinator"},
|
|
154
|
+
metadata={"custom": {"approved_by": approved_by}} if approved_by else None,
|
|
155
|
+
)
|
|
156
|
+
self._verdict_store.put(v)
|
|
157
|
+
context.verdict_chain.append(v.id)
|
|
158
|
+
|
|
159
|
+
context.state = IncidentState.RESOLVED
|
|
160
|
+
self._context_store.save(context)
|
|
161
|
+
return context
|
|
162
|
+
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
logger.error("approve_execution_failed", error=str(exc))
|
|
165
|
+
|
|
166
|
+
v = verdict_create(
|
|
167
|
+
subject={
|
|
168
|
+
"type": "remediation",
|
|
169
|
+
"ref": context.id,
|
|
170
|
+
"summary": f"approval failed: {action} on {target}",
|
|
171
|
+
},
|
|
172
|
+
judgment={
|
|
173
|
+
"action": "escalate",
|
|
174
|
+
"confidence": 0.0,
|
|
175
|
+
"reasoning": f"Approved action failed: {exc}",
|
|
176
|
+
},
|
|
177
|
+
producer={"system": "nthlayer-respond", "instance": "coordinator"},
|
|
178
|
+
metadata={"custom": {"approved_by": approved_by}} if approved_by else None,
|
|
179
|
+
)
|
|
180
|
+
self._verdict_store.put(v)
|
|
181
|
+
context.verdict_chain.append(v.id)
|
|
182
|
+
|
|
183
|
+
context.state = IncidentState.ESCALATED
|
|
184
|
+
self._context_store.save(context)
|
|
185
|
+
return context
|
|
186
|
+
|
|
187
|
+
async def reject(
|
|
188
|
+
self, incident_id: str, reason: str, rejected_by: str | None = None
|
|
189
|
+
) -> IncidentContext:
|
|
190
|
+
"""Reject a proposed remediation action.
|
|
191
|
+
|
|
192
|
+
Requires state == AWAITING_APPROVAL.
|
|
193
|
+
Resolves the last remediation verdict as "overridden" and sets
|
|
194
|
+
state -> ESCALATED.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
incident_id: Incident to reject.
|
|
198
|
+
reason: Human-readable reason for rejection.
|
|
199
|
+
rejected_by: Identity of the rejector (e.g. email). Stored in
|
|
200
|
+
the override reasoning for auditability. Defaults to "human"
|
|
201
|
+
when not provided.
|
|
202
|
+
"""
|
|
203
|
+
context = self._context_store.load(incident_id)
|
|
204
|
+
if context is None:
|
|
205
|
+
raise ValueError(f"Incident {incident_id!r} not found in context store")
|
|
206
|
+
if context.state != IncidentState.AWAITING_APPROVAL:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Incident {incident_id!r} is in state {context.state.value}, "
|
|
209
|
+
f"not AWAITING_APPROVAL"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
remediation = context.remediation
|
|
213
|
+
proposed_action = remediation.proposed_action if remediation else "unknown"
|
|
214
|
+
target = remediation.target if remediation else "unknown"
|
|
215
|
+
|
|
216
|
+
who = rejected_by or "human"
|
|
217
|
+
|
|
218
|
+
# Resolve the last verdict in the chain as overridden
|
|
219
|
+
if context.verdict_chain:
|
|
220
|
+
last_verdict_id = context.verdict_chain[-1]
|
|
221
|
+
try:
|
|
222
|
+
self._verdict_store.resolve(
|
|
223
|
+
last_verdict_id,
|
|
224
|
+
"overridden",
|
|
225
|
+
override={
|
|
226
|
+
"by": who,
|
|
227
|
+
"reasoning": (
|
|
228
|
+
f"{who} rejected {proposed_action} of {target}: {reason}"
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
except Exception as exc: # noqa: BLE001
|
|
233
|
+
logger.warning(
|
|
234
|
+
"reject_verdict_resolve_failed",
|
|
235
|
+
verdict_id=last_verdict_id,
|
|
236
|
+
error=str(exc),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
context.state = IncidentState.ESCALATED
|
|
240
|
+
self._context_store.save(context)
|
|
241
|
+
return context
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------ #
|
|
244
|
+
# Internal pipeline execution #
|
|
245
|
+
# ------------------------------------------------------------------ #
|
|
246
|
+
|
|
247
|
+
async def _run_pipeline(self, context: IncidentContext) -> IncidentContext:
|
|
248
|
+
"""Walk through pipeline steps, running agents and checking gates."""
|
|
249
|
+
# Worker-mode invariant: validate state on entry. AWAITING_APPROVAL is a
|
|
250
|
+
# non-progressable wait state under polling-driven invocation; without
|
|
251
|
+
# this guard, _next_step() would return 3 (since last_completed_step_index
|
|
252
|
+
# = 2 at the gate) and step 3 would proceed, bypassing the approval gate.
|
|
253
|
+
# Resumption from AWAITING_APPROVAL is P3-E.3's approval-verdict polling
|
|
254
|
+
# path; in P3-E.1, AWAITING_APPROVAL incidents simply wait.
|
|
255
|
+
if context.state == IncidentState.AWAITING_APPROVAL:
|
|
256
|
+
return context
|
|
257
|
+
|
|
258
|
+
start_step = self._next_step(context)
|
|
259
|
+
if start_step is None:
|
|
260
|
+
# Already complete
|
|
261
|
+
if context.state not in TERMINAL_STATES:
|
|
262
|
+
context.state = IncidentState.RESOLVED
|
|
263
|
+
self._context_store.save(context)
|
|
264
|
+
return context
|
|
265
|
+
|
|
266
|
+
for step_index in range(start_step, len(PIPELINE)):
|
|
267
|
+
step_roles = PIPELINE[step_index]
|
|
268
|
+
|
|
269
|
+
# Before step 3 (second communication): skip if escalated or failed
|
|
270
|
+
if step_index == 3 and context.state in {
|
|
271
|
+
IncidentState.ESCALATED,
|
|
272
|
+
IncidentState.FAILED,
|
|
273
|
+
}:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
# Update state to reflect current phase
|
|
277
|
+
new_state = _STEP_STATES.get(step_index)
|
|
278
|
+
if new_state is not None:
|
|
279
|
+
context.state = new_state
|
|
280
|
+
|
|
281
|
+
# Execute step (P3-E.1: bounded by step_timeout_seconds when set on config)
|
|
282
|
+
timeout = self._step_timeout()
|
|
283
|
+
try:
|
|
284
|
+
if len(step_roles) == 1:
|
|
285
|
+
coro = self._run_serial_step(context, step_roles[0])
|
|
286
|
+
else:
|
|
287
|
+
coro = self._run_parallel_step(context, step_roles)
|
|
288
|
+
if timeout is not None:
|
|
289
|
+
await asyncio.wait_for(coro, timeout=timeout)
|
|
290
|
+
else:
|
|
291
|
+
await coro
|
|
292
|
+
except asyncio.TimeoutError:
|
|
293
|
+
step_label = (
|
|
294
|
+
step_roles[0].value
|
|
295
|
+
if len(step_roles) == 1
|
|
296
|
+
else "+".join(r.value for r in step_roles)
|
|
297
|
+
)
|
|
298
|
+
context.state = IncidentState.FAILED
|
|
299
|
+
context.error = f"step_timeout: {step_label} exceeded {timeout}s"
|
|
300
|
+
self._context_store.save(context)
|
|
301
|
+
return context
|
|
302
|
+
|
|
303
|
+
# Persist after step
|
|
304
|
+
context.last_completed_step_index = step_index
|
|
305
|
+
self._context_store.save(context)
|
|
306
|
+
|
|
307
|
+
# After triage (step 0): fire on-call escalation if configured
|
|
308
|
+
if step_index == 0 and self._escalation_runner is not None:
|
|
309
|
+
await self._maybe_start_escalation(context)
|
|
310
|
+
|
|
311
|
+
# Gate: escalation check
|
|
312
|
+
if self._check_escalation(context):
|
|
313
|
+
context.state = IncidentState.ESCALATED
|
|
314
|
+
self._context_store.save(context)
|
|
315
|
+
return context
|
|
316
|
+
|
|
317
|
+
# Gate: human approval (after remediation step, index 2)
|
|
318
|
+
if step_index == 2:
|
|
319
|
+
if (
|
|
320
|
+
context.remediation is not None
|
|
321
|
+
and context.remediation.requires_human_approval
|
|
322
|
+
):
|
|
323
|
+
context.state = IncidentState.AWAITING_APPROVAL
|
|
324
|
+
context.updated_at = datetime.now(timezone.utc).isoformat()
|
|
325
|
+
self._context_store.save(context)
|
|
326
|
+
return context
|
|
327
|
+
|
|
328
|
+
# All steps complete
|
|
329
|
+
if context.state not in TERMINAL_STATES:
|
|
330
|
+
context.state = IncidentState.RESOLVED
|
|
331
|
+
self._context_store.save(context)
|
|
332
|
+
|
|
333
|
+
return context
|
|
334
|
+
|
|
335
|
+
async def _run_serial_step(
|
|
336
|
+
self, context: IncidentContext, role: AgentRole
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Run a single agent synchronously."""
|
|
339
|
+
agent = self._agents[role]
|
|
340
|
+
logger.info("step_start", role=role.value, incident=context.id)
|
|
341
|
+
await agent.execute(context)
|
|
342
|
+
logger.info("step_complete", role=role.value, incident=context.id)
|
|
343
|
+
|
|
344
|
+
async def _run_parallel_step(
|
|
345
|
+
self, context: IncidentContext, roles: list[AgentRole]
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Run multiple agents in parallel via asyncio.gather.
|
|
348
|
+
|
|
349
|
+
Each agent writes to a different field on context, so no data race.
|
|
350
|
+
Investigation failure is critical; communication failure is non-blocking.
|
|
351
|
+
"""
|
|
352
|
+
tasks = [self._agents[role].execute(context) for role in roles]
|
|
353
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
354
|
+
|
|
355
|
+
for role, result in zip(roles, results):
|
|
356
|
+
if isinstance(result, Exception):
|
|
357
|
+
if role == AgentRole.INVESTIGATION:
|
|
358
|
+
logger.error(
|
|
359
|
+
"investigation_failed",
|
|
360
|
+
error=str(result),
|
|
361
|
+
incident=context.id,
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
logger.warning(
|
|
365
|
+
"communication_failed",
|
|
366
|
+
error=str(result),
|
|
367
|
+
incident=context.id,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------------------ #
|
|
371
|
+
# On-call escalation #
|
|
372
|
+
# ------------------------------------------------------------------ #
|
|
373
|
+
|
|
374
|
+
async def _maybe_start_escalation(self, context: IncidentContext) -> None:
|
|
375
|
+
"""Fire the escalation runner if the manifest has an oncall config.
|
|
376
|
+
|
|
377
|
+
Fail-open: escalation failure never blocks the incident pipeline.
|
|
378
|
+
"""
|
|
379
|
+
from nthlayer_workers.respond.notification_backends.protocol import NotificationPayload
|
|
380
|
+
from nthlayer_workers.respond.oncall.escalation import EscalationStep
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
svc_ctx = context.metadata.get("service_context", {})
|
|
384
|
+
oncall = (
|
|
385
|
+
svc_ctx.get("spec", {})
|
|
386
|
+
.get("ownership", {})
|
|
387
|
+
.get("oncall")
|
|
388
|
+
)
|
|
389
|
+
if not oncall:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
# Parse escalation steps from manifest config
|
|
393
|
+
raw_steps = oncall.get("escalation", [])
|
|
394
|
+
if not raw_steps:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
steps = []
|
|
398
|
+
for raw in raw_steps:
|
|
399
|
+
after_str = raw["after"]
|
|
400
|
+
if not after_str.endswith("m") or not after_str[:-1].isdigit():
|
|
401
|
+
logger.warning(
|
|
402
|
+
"escalation_step_invalid_after",
|
|
403
|
+
after=after_str,
|
|
404
|
+
incident_id=context.id,
|
|
405
|
+
)
|
|
406
|
+
continue
|
|
407
|
+
minutes = int(after_str[:-1])
|
|
408
|
+
steps.append(
|
|
409
|
+
EscalationStep(
|
|
410
|
+
after=timedelta(minutes=minutes),
|
|
411
|
+
notify=raw["notify"],
|
|
412
|
+
target=raw.get("target"),
|
|
413
|
+
phone=raw.get("phone"),
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
severity = getattr(context.triage, "severity", 3) if context.triage else 3
|
|
418
|
+
title = context.triage.reasoning[:80] if context.triage and context.triage.reasoning else context.id
|
|
419
|
+
|
|
420
|
+
payload = NotificationPayload(
|
|
421
|
+
incident_id=context.id,
|
|
422
|
+
severity=severity,
|
|
423
|
+
title=title,
|
|
424
|
+
summary=context.triage.reasoning if context.triage else "Incident triggered",
|
|
425
|
+
root_cause=None,
|
|
426
|
+
blast_radius=list(context.triage.blast_radius) if context.triage else [],
|
|
427
|
+
actions_url=None,
|
|
428
|
+
escalation_step=0,
|
|
429
|
+
requires_ack=True,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
await self._escalation_runner.start_escalation(
|
|
433
|
+
incident_id=context.id,
|
|
434
|
+
payload=payload,
|
|
435
|
+
steps=steps,
|
|
436
|
+
)
|
|
437
|
+
logger.info(
|
|
438
|
+
"escalation_triggered",
|
|
439
|
+
incident_id=context.id,
|
|
440
|
+
steps=len(steps),
|
|
441
|
+
)
|
|
442
|
+
except Exception as exc: # noqa: BLE001
|
|
443
|
+
logger.warning(
|
|
444
|
+
"escalation_start_failed",
|
|
445
|
+
incident_id=context.id,
|
|
446
|
+
error=str(exc),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# ------------------------------------------------------------------ #
|
|
450
|
+
# Gates #
|
|
451
|
+
# ------------------------------------------------------------------ #
|
|
452
|
+
|
|
453
|
+
def _check_escalation(self, context: IncidentContext) -> bool:
|
|
454
|
+
"""Return True if any agent has flagged this incident for escalation.
|
|
455
|
+
|
|
456
|
+
P3-E.1: capture-at-write-time. The flag is set inside ``_emit_verdict``
|
|
457
|
+
(agents/base.py) when a verdict has ``action=escalate`` and
|
|
458
|
+
``confidence < escalation_threshold``. The gate now reads a single
|
|
459
|
+
boolean from context.metadata — no verdict-store re-fetch, no
|
|
460
|
+
per-step API round-trip in worker mode.
|
|
461
|
+
"""
|
|
462
|
+
return bool(context.metadata.get("escalation_pending", False))
|
|
463
|
+
|
|
464
|
+
def _step_timeout(self) -> float | None:
|
|
465
|
+
"""Return the per-step timeout, or None if not configured.
|
|
466
|
+
|
|
467
|
+
Defensive against test mocks that don't set the field. Returns the
|
|
468
|
+
configured value only when it is a positive number; otherwise None
|
|
469
|
+
(no timeout, used by older tests with MagicMock configs).
|
|
470
|
+
"""
|
|
471
|
+
val = getattr(self._config, "step_timeout_seconds", None)
|
|
472
|
+
if isinstance(val, (int, float)) and val > 0:
|
|
473
|
+
return float(val)
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _next_step(context: IncidentContext) -> int | None:
|
|
478
|
+
"""Determine the next pipeline step to execute.
|
|
479
|
+
|
|
480
|
+
Returns None if all steps are complete.
|
|
481
|
+
"""
|
|
482
|
+
if context.last_completed_step_index is None:
|
|
483
|
+
return 0
|
|
484
|
+
next_idx = context.last_completed_step_index + 1
|
|
485
|
+
if next_idx >= len(PIPELINE):
|
|
486
|
+
return None
|
|
487
|
+
return next_idx
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Prometheus metrics from the verdict store.
|
|
2
|
+
|
|
3
|
+
Exposes verdict accuracy, total counts, and reversal rates as gauges.
|
|
4
|
+
Plain text exposition format — no prometheus_client dependency.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from nthlayer_common.verdicts import VerdictFilter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _component_label(producer_system: str) -> str:
|
|
15
|
+
"""Strip 'nthlayer-' prefix for the component label."""
|
|
16
|
+
if producer_system.startswith("nthlayer-"):
|
|
17
|
+
return producer_system[len("nthlayer-"):]
|
|
18
|
+
return producer_system
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VerdictMetricsCollector:
|
|
22
|
+
"""Collect verdict metrics from the store and render as Prometheus text."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, verdict_store: Any) -> None:
|
|
25
|
+
self._store = verdict_store
|
|
26
|
+
|
|
27
|
+
def collect(self) -> str:
|
|
28
|
+
"""Query the verdict store and return Prometheus text exposition."""
|
|
29
|
+
# Query all verdicts (no time filter for totals)
|
|
30
|
+
all_verdicts = self._store.query(VerdictFilter(limit=0))
|
|
31
|
+
|
|
32
|
+
# Group by (producer_system, subject_type)
|
|
33
|
+
groups: dict[tuple[str, str], list] = {}
|
|
34
|
+
for v in all_verdicts:
|
|
35
|
+
key = (v.producer.system, v.subject.type)
|
|
36
|
+
groups.setdefault(key, []).append(v)
|
|
37
|
+
|
|
38
|
+
if not groups:
|
|
39
|
+
# Emit a general comment so text still starts with "# "
|
|
40
|
+
return "# nthlayer_respond metrics: no verdicts recorded\n"
|
|
41
|
+
|
|
42
|
+
lines: list[str] = []
|
|
43
|
+
|
|
44
|
+
# Emit HELP/TYPE headers only when there is data
|
|
45
|
+
lines.append("# HELP nthlayer_verdicts_total Total number of verdicts by component and type")
|
|
46
|
+
lines.append("# TYPE nthlayer_verdicts_total gauge")
|
|
47
|
+
|
|
48
|
+
now = datetime.now(tz=timezone.utc)
|
|
49
|
+
windows = {"7d": timedelta(days=7), "30d": timedelta(days=30)}
|
|
50
|
+
|
|
51
|
+
# Collect accuracy/reversal lines separately so we can emit headers only if needed
|
|
52
|
+
accuracy_lines: list[str] = []
|
|
53
|
+
|
|
54
|
+
for (producer, subject_type), verdicts in sorted(groups.items()):
|
|
55
|
+
component = _component_label(producer)
|
|
56
|
+
|
|
57
|
+
# Total count (no window)
|
|
58
|
+
lines.append(
|
|
59
|
+
f'nthlayer_verdicts_total{{component="{component}",'
|
|
60
|
+
f'verdict_type="{subject_type}"}} {len(verdicts)}'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Accuracy and reversal rate per window
|
|
64
|
+
for window_label, delta in windows.items():
|
|
65
|
+
cutoff = now - delta
|
|
66
|
+
windowed = [
|
|
67
|
+
v for v in verdicts
|
|
68
|
+
if v.timestamp and v.timestamp >= cutoff
|
|
69
|
+
]
|
|
70
|
+
if not windowed:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
resolved = [
|
|
74
|
+
v for v in windowed
|
|
75
|
+
if v.outcome and v.outcome.status in ("confirmed", "overridden", "partial")
|
|
76
|
+
]
|
|
77
|
+
if not resolved:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
overridden = sum(
|
|
81
|
+
1 for v in resolved if v.outcome.status == "overridden"
|
|
82
|
+
)
|
|
83
|
+
total_resolved = len(resolved)
|
|
84
|
+
accuracy = 1.0 - (overridden / total_resolved)
|
|
85
|
+
reversal = overridden / total_resolved
|
|
86
|
+
|
|
87
|
+
accuracy_lines.append(
|
|
88
|
+
f'nthlayer_verdict_accuracy{{component="{component}",'
|
|
89
|
+
f'verdict_type="{subject_type}",window="{window_label}"}} {accuracy:.4f}'
|
|
90
|
+
)
|
|
91
|
+
accuracy_lines.append(
|
|
92
|
+
f'nthlayer_verdict_reversal_rate{{component="{component}",'
|
|
93
|
+
f'verdict_type="{subject_type}",window="{window_label}"}} {reversal:.4f}'
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if accuracy_lines:
|
|
97
|
+
lines.append("# HELP nthlayer_verdict_accuracy Verdict accuracy (1 - reversal rate) by component")
|
|
98
|
+
lines.append("# TYPE nthlayer_verdict_accuracy gauge")
|
|
99
|
+
lines.append("# HELP nthlayer_verdict_reversal_rate Verdict reversal rate (overridden / total resolved) by component")
|
|
100
|
+
lines.append("# TYPE nthlayer_verdict_reversal_rate gauge")
|
|
101
|
+
lines.extend(accuracy_lines)
|
|
102
|
+
|
|
103
|
+
lines.append("") # trailing newline
|
|
104
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Notification delivery backends for on-call escalation."""
|