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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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."""