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,109 @@
1
+ # src/nthlayer_respond/types.py
2
+ """Core data types for Mayday."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+
9
+ class IncidentState(str, Enum):
10
+ TRIGGERED = "triggered"
11
+ TRIAGING = "triaging"
12
+ INVESTIGATING = "investigating"
13
+ REMEDIATING = "remediating"
14
+ AWAITING_APPROVAL = "awaiting_approval"
15
+ RESOLVED = "resolved"
16
+ ESCALATED = "escalated"
17
+ FAILED = "failed"
18
+
19
+
20
+ TERMINAL_STATES = frozenset({
21
+ IncidentState.RESOLVED,
22
+ IncidentState.ESCALATED,
23
+ IncidentState.FAILED,
24
+ })
25
+
26
+
27
+ class AgentRole(str, Enum):
28
+ TRIAGE = "triage"
29
+ INVESTIGATION = "investigation"
30
+ COMMUNICATION = "communication"
31
+ REMEDIATION = "remediation"
32
+
33
+
34
+ @dataclass
35
+ class TriageResult:
36
+ severity: int # 0-4 (P0-P4)
37
+ blast_radius: list[str]
38
+ affected_slos: list[str]
39
+ assigned_team: str | None
40
+ reasoning: str
41
+ confidence: float | None = None # from model response; None = not yet parsed
42
+
43
+
44
+ @dataclass
45
+ class Hypothesis:
46
+ description: str
47
+ confidence: float # 0.0-1.0
48
+ evidence: list[str]
49
+ change_candidate: str | None
50
+
51
+
52
+ @dataclass
53
+ class InvestigationResult:
54
+ hypotheses: list[Hypothesis]
55
+ root_cause: str | None
56
+ root_cause_confidence: float
57
+ reasoning: str
58
+ confidence: float | None = None
59
+
60
+
61
+ @dataclass
62
+ class CommunicationUpdate:
63
+ channel: str
64
+ timestamp: str # ISO 8601
65
+ update_type: str # "initial" or "resolution"
66
+ content: str
67
+
68
+
69
+ @dataclass
70
+ class CommunicationResult:
71
+ updates_sent: list[CommunicationUpdate] = field(default_factory=list)
72
+ reasoning: str = ""
73
+ confidence: float | None = None
74
+
75
+
76
+ @dataclass
77
+ class RemediationResult:
78
+ proposed_action: str | None = None
79
+ target: str | None = None
80
+ risk_assessment: str = ""
81
+ requires_human_approval: bool = True
82
+ executed: bool = False
83
+ execution_result: str | None = None
84
+ autonomy_reduced: bool = False
85
+ autonomy_target: str | None = None
86
+ previous_autonomy_level: str | None = None
87
+ new_autonomy_level: str | None = None
88
+ reasoning: str = ""
89
+ autonomy_reduction: dict | None = None
90
+ confidence: float | None = None
91
+
92
+
93
+ @dataclass
94
+ class IncidentContext:
95
+ id: str # INC-YYYY-NNNN
96
+ state: IncidentState
97
+ created_at: str # ISO 8601
98
+ updated_at: str
99
+ trigger_source: str # "nthlayer-correlate", "pagerduty", "manual"
100
+ trigger_verdict_ids: list[str]
101
+ topology: dict
102
+ triage: TriageResult | None = None
103
+ investigation: InvestigationResult | None = None
104
+ communication: CommunicationResult | None = None
105
+ remediation: RemediationResult | None = None
106
+ verdict_chain: list[str] = field(default_factory=list)
107
+ last_completed_step_index: int | None = None # pipeline step 0-3
108
+ error: str | None = None
109
+ metadata: dict = field(default_factory=dict)
@@ -0,0 +1,56 @@
1
+ """Worker-mode verdict submission to core (P3-E.1).
2
+
3
+ Wraps a Verdict dataclass in a CloudEvents v1.0 envelope and submits it to
4
+ core via CoreAPIClient. Submission failure is logged and metered but does
5
+ not crash the cycle — the Verdict object is already constructed locally
6
+ and lineage is intact in IncidentContext.verdict_chain, so the next agent's
7
+ verdict will still chain correctly.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import structlog
12
+
13
+ from nthlayer_common.api_client import CoreAPIClient
14
+ from nthlayer_common.cloudevents import wrap_verdict
15
+ from nthlayer_common.metrics import errors_total
16
+ from nthlayer_common.verdicts import Verdict, to_dict
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+
21
+ async def submit_verdict_to_core(
22
+ client: CoreAPIClient,
23
+ verdict: Verdict,
24
+ *,
25
+ deployment_id: str | None = None,
26
+ ) -> bool:
27
+ """Submit a verdict to core via the CloudEvents-wrapped /verdicts endpoint.
28
+
29
+ Returns True on success, False on submission failure. The caller is
30
+ responsible for deciding whether to chain subsequent verdicts to a
31
+ failed one — current respond callers (``_emit_verdict``) skip appending
32
+ on failure so ``verdict_chain[-1]`` always references a verdict that
33
+ actually exists in core. This keeps the in-memory chain consistent with
34
+ the core-side lineage graph; otherwise consumers walking ancestry via
35
+ ``parent_ids`` would dead-end on dangling references.
36
+
37
+ Failures are logged and counted; this function never raises. Operator
38
+ visibility comes from the errors_total Prometheus metric
39
+ (component="respond", error_type="verdict_submit").
40
+ """
41
+ envelope = wrap_verdict(
42
+ to_dict(verdict),
43
+ component="respond",
44
+ deployment_id=deployment_id,
45
+ )
46
+ result = await client.submit_verdict(envelope["data"])
47
+ if not result.ok:
48
+ logger.warning(
49
+ "respond_verdict_submit_failed",
50
+ verdict_id=verdict.id,
51
+ status_code=result.status_code,
52
+ error=result.error,
53
+ )
54
+ errors_total.labels(component="respond", error_type="verdict_submit").inc()
55
+ return False
56
+ return True
@@ -0,0 +1,533 @@
1
+ """RespondModule — worker module driving incident response (P3-E.1).
2
+
3
+ Polls correlation_snapshot assessments (primary) and quality_breach verdicts
4
+ (fallback) from core via CoreAPIClient. Opens incidents, runs the agent
5
+ pipeline via Coordinator, submits verdicts back to core, persists incident
6
+ state to component_state for crash recovery.
7
+
8
+ The worker is the sole owner of in-memory incident state. Agents and the
9
+ coordinator have NO back-reference to the worker — `self._incidents` is
10
+ mutated only by `_drive_active_incidents` after `coordinator.run(context)`
11
+ completes for each incident, and by `_ingest_triggers` before the drive phase.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timedelta, timezone
17
+
18
+ import structlog
19
+
20
+ from nthlayer_common.api_client import CoreAPIClient
21
+
22
+ from nthlayer_workers.respond.config import RespondConfig
23
+ from nthlayer_workers.respond.context_store import (
24
+ incident_context_from_dict,
25
+ incident_context_to_dict,
26
+ )
27
+ from nthlayer_workers.respond.coordinator import Coordinator
28
+ from nthlayer_workers.respond.types import (
29
+ IncidentContext,
30
+ IncidentState,
31
+ TERMINAL_STATES,
32
+ )
33
+ from nthlayer_workers.respond.worker_helpers import (
34
+ breach_ids_with_snapshots,
35
+ filter_after,
36
+ open_from_breach,
37
+ open_from_snapshot,
38
+ )
39
+
40
+ logger = structlog.get_logger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class Cursors:
45
+ """Polling cursors for trigger ingestion. Persisted via component_state."""
46
+ snapshot_after: str | None = None
47
+ breach_after: str | None = None
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "snapshot_after": self.snapshot_after,
52
+ "breach_after": self.breach_after,
53
+ }
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict) -> Cursors:
57
+ return cls(
58
+ snapshot_after=data.get("snapshot_after"),
59
+ breach_after=data.get("breach_after"),
60
+ )
61
+
62
+
63
+ def _is_terminal_and_aged(context: IncidentContext, retention_seconds: float) -> bool:
64
+ """True if context is in a terminal state AND older than retention window.
65
+
66
+ Terminal states (RESOLVED/ESCALATED/FAILED) are kept for retention_seconds
67
+ so operators can inspect closed incidents; older terminal incidents are
68
+ pruned on the next get_state() call.
69
+ """
70
+ if context.state not in TERMINAL_STATES:
71
+ return False
72
+ try:
73
+ # updated_at is ISO 8601 (set by coordinator and ingest paths)
74
+ updated = datetime.fromisoformat(context.updated_at.replace("Z", "+00:00"))
75
+ if updated.tzinfo is None:
76
+ # Naive datetime — treat as UTC for the subtraction. Without this
77
+ # the next line raises TypeError ("can't subtract offset-naive
78
+ # and offset-aware datetimes"), which would block every save.
79
+ updated = updated.replace(tzinfo=timezone.utc)
80
+ age = (datetime.now(timezone.utc) - updated).total_seconds()
81
+ except (ValueError, AttributeError, TypeError) as exc:
82
+ # Malformed timestamp → treat as not-aged so we don't drop accidentally.
83
+ # Debug-level so it's invisible until needed for diagnosis.
84
+ logger.debug(
85
+ "respond_terminal_age_check_skipped_malformed_timestamp",
86
+ updated_at=context.updated_at,
87
+ error=str(exc),
88
+ )
89
+ return False
90
+ return age >= retention_seconds
91
+
92
+
93
+ class _NoopContextStore:
94
+ """Satisfies the coordinator's context_store interface without persisting.
95
+
96
+ Worker mode owns persistence at the cycle boundary, not per-step. The
97
+ coordinator's existing save(context) calls become no-ops here. Reads
98
+ are NEVER expected in worker mode — the worker holds the live
99
+ IncidentContext in self._incidents — so load() and list_active() raise
100
+ loudly rather than returning silent-wrong-answer values like None or [].
101
+ Loud failure on misuse beats invisible bugs.
102
+ """
103
+ def save(self, context: IncidentContext) -> None:
104
+ pass
105
+
106
+ def load(self, incident_id: str) -> IncidentContext | None:
107
+ raise NotImplementedError(
108
+ "worker mode reads from RespondModule._incidents directly, not context_store"
109
+ )
110
+
111
+ def list_active(self) -> list[str]:
112
+ raise NotImplementedError(
113
+ "worker mode reads from RespondModule._incidents directly, not context_store"
114
+ )
115
+
116
+ def list_all(self, limit: int = 50) -> list[IncidentContext]:
117
+ raise NotImplementedError(
118
+ "worker mode reads from RespondModule._incidents directly, not context_store"
119
+ )
120
+
121
+ def get_metadata(self, key: str) -> str | None:
122
+ raise NotImplementedError("worker mode does not use context_store metadata")
123
+
124
+ def set_metadata(self, key: str, value: str) -> None:
125
+ raise NotImplementedError("worker mode does not use context_store metadata")
126
+
127
+ def close(self) -> None:
128
+ pass
129
+
130
+
131
+ @dataclass
132
+ class RespondModule:
133
+ """Respond worker module — drives incidents from triggers polled from core API.
134
+
135
+ Reads correlation_snapshot assessments (primary) and quality_breach verdicts
136
+ (fallback) from core, opens incident contexts, runs the agent pipeline,
137
+ submits verdicts back to core, persists incident state to component_state.
138
+
139
+ **Single-instance contract (v1.5):** RespondModule does NOT acquire a lease
140
+ on triggers or incidents. Two RespondModule instances polling the same core
141
+ will both ingest the same snapshots/breaches and open duplicate incidents
142
+ (different INC-... ids, identical trigger_verdict_ids). v1.5 deployments
143
+ must run exactly one workers process per core. Multi-instance HA is a v2
144
+ concern (see spec "Out of P3-E.1 scope" — case-API integration covers
145
+ lease-based ownership for incidents).
146
+ """
147
+ client: CoreAPIClient
148
+ config: RespondConfig
149
+ deployment_id: str | None = None
150
+
151
+ # In-memory mirrors of component_state, restored on restore_state()
152
+ _cursors: Cursors = field(default_factory=Cursors)
153
+ _incidents: dict[str, IncidentContext] = field(default_factory=dict)
154
+
155
+ # Lazy: agents and coordinator constructed on first drive cycle. Tests
156
+ # may pre-set these via _agents/_coordinator to inject mocks.
157
+ _agents: dict | None = None
158
+ _coordinator: Coordinator | None = None
159
+
160
+ @property
161
+ def name(self) -> str:
162
+ # Single-module form for v1.5. If the spec's planned split into
163
+ # RespondTriggerModule (fast trigger detection) and RespondPipelineModule
164
+ # (slow pipeline drive) is implemented, rename to "respond.pipeline" to
165
+ # match the observe.{collect,drift,topology} / correlate.{session,topology,
166
+ # contract} naming convention used elsewhere in workers.
167
+ return "respond"
168
+
169
+ async def restore_state(self, state: dict | None) -> None:
170
+ """Reload cursors and active incidents from component_state."""
171
+ if state is None:
172
+ return
173
+ self._cursors = Cursors.from_dict(state.get("cursors") or {})
174
+ for incident_id, raw in (state.get("incidents") or {}).items():
175
+ try:
176
+ self._incidents[incident_id] = incident_context_from_dict(raw)
177
+ except (KeyError, ValueError, TypeError):
178
+ logger.warning(
179
+ "respond_state_restore_skipped_corrupt_incident",
180
+ incident_id=incident_id,
181
+ )
182
+
183
+ async def process_cycle(self) -> None:
184
+ """One cycle: detect new triggers → drive active incidents.
185
+
186
+ State is persisted by the runner via get_state() after this returns.
187
+ """
188
+ await self._ingest_triggers()
189
+ await self._drive_active_incidents()
190
+
191
+ # ------------------------------------------------------------------ #
192
+ # Pipeline drive #
193
+ # ------------------------------------------------------------------ #
194
+
195
+ def _ensure_coordinator(self) -> Coordinator:
196
+ """Lazily construct agents + coordinator on first drive cycle."""
197
+ if self._coordinator is not None:
198
+ return self._coordinator
199
+
200
+ if self._agents is None:
201
+ self._agents = self._build_agents()
202
+
203
+ self._coordinator = Coordinator(
204
+ agents=self._agents,
205
+ context_store=_NoopContextStore(),
206
+ verdict_store=None, # worker mode: agents submit via CoreAPIClient
207
+ config=self.config,
208
+ safe_action_registry=None, # P3-E.3
209
+ escalation_runner=None, # P3-E.5
210
+ )
211
+ return self._coordinator
212
+
213
+ def _build_agents(self) -> dict:
214
+ """Construct the agent stack with worker-mode (client-based) plumbing."""
215
+ # Local imports to avoid pulling agent modules at import time of
216
+ # respond.worker (keeps test collection fast and avoids prompt-file
217
+ # FileNotFoundError on environments where prompts aren't bundled).
218
+ from nthlayer_workers.respond.agents.communication import CommunicationAgent
219
+ from nthlayer_workers.respond.agents.investigation import InvestigationAgent
220
+ from nthlayer_workers.respond.agents.remediation import RemediationAgent
221
+ from nthlayer_workers.respond.agents.triage import TriageAgent
222
+ from nthlayer_workers.respond.types import AgentRole
223
+
224
+ agent_config = {
225
+ "root_cause_threshold": self.config.root_cause_threshold,
226
+ "arbiter_url": self.config.arbiter_url,
227
+ "escalation_threshold": self.config.escalation_threshold,
228
+ }
229
+ kwargs = {
230
+ "client": self.client,
231
+ "deployment_id": self.deployment_id,
232
+ }
233
+ return {
234
+ AgentRole.TRIAGE: TriageAgent(
235
+ self.config.model, self.config.max_tokens, None, agent_config,
236
+ timeout=self.config.triage_timeout, **kwargs,
237
+ ),
238
+ AgentRole.INVESTIGATION: InvestigationAgent(
239
+ self.config.model, self.config.max_tokens, None, agent_config,
240
+ timeout=self.config.investigation_timeout, **kwargs,
241
+ ),
242
+ AgentRole.COMMUNICATION: CommunicationAgent(
243
+ self.config.model, self.config.max_tokens, None, agent_config,
244
+ timeout=self.config.communication_timeout, **kwargs,
245
+ ),
246
+ AgentRole.REMEDIATION: RemediationAgent(
247
+ self.config.model, self.config.max_tokens, None, agent_config,
248
+ timeout=self.config.remediation_timeout,
249
+ safe_action_registry=None, # P3-E.3 wires the registry
250
+ **kwargs,
251
+ ),
252
+ }
253
+
254
+ async def _drive_active_incidents(self) -> None:
255
+ """Run each non-terminal, non-AWAITING_APPROVAL incident through the
256
+ coordinator pipeline.
257
+
258
+ AWAITING_APPROVAL incidents are skipped — coordinator's _run_pipeline
259
+ also early-returns on that state, but skipping here avoids the call
260
+ entirely. Resumption (poll for approval verdict) is P3-E.3.
261
+
262
+ Incidents in TERMINAL_STATES are skipped (they only need terminal
263
+ retention pruning, handled in get_state()).
264
+ """
265
+ # Snapshot the incident IDs to avoid mutation-during-iteration if
266
+ # _ingest_triggers were ever extended to run concurrently with this.
267
+ # Today the cycle is sequential, but the snapshot makes the invariant
268
+ # explicit.
269
+ incident_ids = list(self._incidents.keys())
270
+ if not incident_ids:
271
+ return
272
+
273
+ coordinator = self._ensure_coordinator()
274
+ for incident_id in incident_ids:
275
+ context = self._incidents[incident_id]
276
+ if context.state in TERMINAL_STATES:
277
+ continue
278
+ if context.state == IncidentState.AWAITING_APPROVAL:
279
+ continue
280
+ try:
281
+ updated = await coordinator.run(context)
282
+ self._incidents[incident_id] = updated
283
+ except Exception: # noqa: BLE001
284
+ logger.exception(
285
+ "respond_incident_drive_failed",
286
+ incident_id=incident_id,
287
+ )
288
+ # coordinator.run already catches inner exceptions and sets
289
+ # FAILED. This catch is for unexpected escapes (e.g. errors
290
+ # during coordinator construction). Mark FAILED so the
291
+ # incident doesn't get retried indefinitely.
292
+ context.state = IncidentState.FAILED
293
+ context.error = "unrecoverable: coordinator.run escape"
294
+ self._incidents[incident_id] = context
295
+
296
+ # ------------------------------------------------------------------ #
297
+ # Trigger ingestion #
298
+ # ------------------------------------------------------------------ #
299
+
300
+ async def _ingest_triggers(self) -> None:
301
+ """Detect new triggers and open incidents.
302
+
303
+ Primary path: correlation_snapshot assessments (situation-shaped).
304
+ Fallback path: quality_breach verdicts older than fallback_threshold
305
+ without an associated correlation_snapshot referencing them.
306
+
307
+ Snapshots are fetched once and reused for the fallback dedup check —
308
+ avoids the per-breach API fan-out that would otherwise occur.
309
+ """
310
+ snapshot_cache = await self._ingest_snapshots()
311
+ await self._ingest_orphan_breaches(snapshot_cache)
312
+
313
+ async def _ingest_snapshots(self) -> list[dict]:
314
+ """Primary trigger path: correlation_snapshot assessments.
315
+
316
+ Returns the snapshot page (already filtered by cursor) so the fallback
317
+ path can dedup against it without re-fetching.
318
+ """
319
+ result = await self.client.get_assessments(kind="correlation_snapshot")
320
+ if not result.ok:
321
+ logger.warning(
322
+ "respond_snapshot_poll_failed",
323
+ status_code=result.status_code,
324
+ error=result.error,
325
+ )
326
+ return []
327
+
328
+ all_snapshots = result.data or []
329
+ # Client-side filter by created_after the cursor. v1.5 limitation:
330
+ # core's /assessments API filters by kind only. Mirrors P3-D.1's same
331
+ # constraint in correlate.
332
+ snapshots = filter_after(all_snapshots, self._cursors.snapshot_after)
333
+ if not snapshots:
334
+ return all_snapshots
335
+
336
+ # Sort by created_at ascending so cursor advances monotonically.
337
+ snapshots.sort(key=lambda s: s.get("created_at", ""))
338
+ triggered = self._incidents_triggered_from()
339
+ for snap in snapshots:
340
+ snap_id = snap.get("id")
341
+ created_at = snap.get("created_at")
342
+ if not snap_id or not created_at:
343
+ continue
344
+ if snap_id not in triggered:
345
+ try:
346
+ await self._open_incident_from_snapshot(snap)
347
+ except Exception: # noqa: BLE001
348
+ # Defensive: malformed snapshot must not stall the cursor
349
+ # forever (would block all subsequent snapshots in the
350
+ # batch and on every later cycle).
351
+ logger.exception(
352
+ "respond_snapshot_open_failed",
353
+ snap_id=snap_id,
354
+ )
355
+ # Advance cursor whether opened, skipped (already triggered), or
356
+ # failed (logged). Asymmetry-fix: only advance after the open
357
+ # attempt, not before — matches the spec's restartable-on-failure
358
+ # intent for the snapshot path.
359
+ self._cursors.snapshot_after = created_at
360
+
361
+ return all_snapshots
362
+
363
+ async def _ingest_orphan_breaches(self, snapshot_cache: list[dict]) -> None:
364
+ """Fallback trigger path: quality_breach verdicts without snapshot.
365
+
366
+ For each new quality_breach verdict older than fallback_threshold_seconds:
367
+ check whether a correlation_snapshot exists referencing it. If yes, skip
368
+ (already represented). If no, open an incident from the breach directly.
369
+
370
+ ``snapshot_cache`` is the per-cycle cache from _ingest_snapshots — used
371
+ for parent_ids dedup without re-fetching once per breach. v1.5 limitation:
372
+ if the snapshot list paginates beyond the page limit, snapshots beyond
373
+ page 1 may be missed (logged as v2 follow-up: server-side parent_ids
374
+ filter).
375
+
376
+ Logs the threshold value separately so the metadata.fallback_reason
377
+ string stays stable if the threshold becomes operationally configurable.
378
+ """
379
+ cutoff = datetime.now(timezone.utc) - timedelta(
380
+ seconds=self.config.fallback_threshold_seconds
381
+ )
382
+ cutoff_iso = cutoff.isoformat()
383
+ # Guard against created_after > created_before, which is undefined at
384
+ # the core API level. Can happen with clock skew, a stalled worker
385
+ # whose cursor advanced past now-cutoff, or a manual cursor injection.
386
+ if self._cursors.breach_after and self._cursors.breach_after >= cutoff_iso:
387
+ return
388
+ result = await self.client.get_verdicts(
389
+ verdict_type="quality_breach",
390
+ created_before=cutoff_iso,
391
+ created_after=self._cursors.breach_after,
392
+ )
393
+ if not result.ok:
394
+ logger.warning(
395
+ "respond_breach_poll_failed",
396
+ status_code=result.status_code,
397
+ error=result.error,
398
+ )
399
+ return
400
+
401
+ breaches = result.data or []
402
+ if not breaches:
403
+ return
404
+
405
+ # Build the dedup set once from the snapshot cache, not per-breach.
406
+ snapshotted_breach_ids = breach_ids_with_snapshots(snapshot_cache)
407
+
408
+ breaches.sort(key=lambda b: b.get("created_at", ""))
409
+ triggered = self._incidents_triggered_from()
410
+ for breach in breaches:
411
+ breach_id = breach.get("id")
412
+ created_at = breach.get("created_at")
413
+ if not breach_id or not created_at:
414
+ continue
415
+ try:
416
+ if breach_id in triggered:
417
+ pass # already opened in a prior cycle
418
+ elif breach_id in snapshotted_breach_ids:
419
+ pass # represented by a snapshot incident — fallback skipped
420
+ else:
421
+ opened_id = await self._open_incident_from_breach(breach)
422
+ logger.info(
423
+ "respond_fallback_trigger_used",
424
+ incident_id=opened_id,
425
+ breach_id=breach_id,
426
+ fallback_threshold_seconds=self.config.fallback_threshold_seconds,
427
+ )
428
+ except Exception: # noqa: BLE001
429
+ logger.exception(
430
+ "respond_breach_open_failed",
431
+ breach_id=breach_id,
432
+ )
433
+ # Advance cursor after every breach (opened, skipped, or failed)
434
+ # so a single bad record doesn't permanently stall the cursor.
435
+ self._cursors.breach_after = created_at
436
+
437
+ def _incidents_triggered_from(self) -> set[str]:
438
+ """Union of trigger_verdict_ids across all active incidents.
439
+
440
+ Used to dedup snapshots and breaches if cursor advancement hiccups
441
+ (e.g. process restart with stale cursor). A trigger we've already
442
+ opened an incident for must not produce another incident.
443
+ """
444
+ seen: set[str] = set()
445
+ for ctx in self._incidents.values():
446
+ seen.update(ctx.trigger_verdict_ids)
447
+ return seen
448
+
449
+ # ------------------------------------------------------------------ #
450
+ # Incident opening #
451
+ # ------------------------------------------------------------------ #
452
+
453
+ async def _open_incident_from_snapshot(self, snap: dict) -> str:
454
+ """Open an incident from a correlation_snapshot assessment.
455
+
456
+ Resolves real service tiers from manifests via the core API so the
457
+ triage agent's severity derivation is grounded in declared tier rather
458
+ than a hardcoded placeholder.
459
+ """
460
+ affected = (snap.get("data") or {}).get("affected_services") or [
461
+ (snap.get("data") or {}).get("domain", {}).get("service") or "unknown"
462
+ ]
463
+ tiers = await self._resolve_tiers(affected)
464
+ ctx = open_from_snapshot(snap, self.config, tiers=tiers)
465
+ self._incidents[ctx.id] = ctx
466
+ return ctx.id
467
+
468
+ async def _open_incident_from_breach(self, breach: dict) -> str:
469
+ """Open an incident from a quality_breach verdict (fallback path)."""
470
+ service = (
471
+ breach.get("service")
472
+ or (breach.get("subject") or {}).get("service")
473
+ or "unknown"
474
+ )
475
+ tiers = await self._resolve_tiers([service])
476
+ ctx = open_from_breach(breach, self.config, tiers=tiers)
477
+ self._incidents[ctx.id] = ctx
478
+ return ctx.id
479
+
480
+ async def _resolve_tiers(self, services: list[str]) -> dict[str, str]:
481
+ """Fetch tiers for the given services from core's manifest catalogue.
482
+
483
+ Returns a service -> tier mapping. Services with no manifest, manifest
484
+ fetch failures, or transport-level exceptions all fall back to
485
+ ``"standard"`` so triage still receives a reasonable default — a
486
+ manifest-fetch failure must not prevent an incident from being opened
487
+ (drop-the-trigger would be a worse outcome than degrade-to-standard).
488
+ Failures are logged so operators can spot manifest registration gaps.
489
+ """
490
+ tiers: dict[str, str] = {}
491
+ for svc in services:
492
+ if not svc or svc in tiers:
493
+ continue
494
+ try:
495
+ result = await self.client.get_manifest(svc)
496
+ except Exception: # noqa: BLE001
497
+ # CoreAPIClient is designed to never raise, but we defensively
498
+ # degrade rather than propagate — a network blip during tier
499
+ # lookup must not drop the incident.
500
+ logger.warning(
501
+ "respond_manifest_lookup_exception_fallback_to_standard",
502
+ service=svc,
503
+ exc_info=True,
504
+ )
505
+ tiers[svc] = "standard"
506
+ continue
507
+ if result.ok and isinstance(result.data, dict):
508
+ tiers[svc] = result.data.get("tier", "standard") or "standard"
509
+ else:
510
+ logger.info(
511
+ "respond_manifest_lookup_fallback_to_standard",
512
+ service=svc,
513
+ status_code=getattr(result, "status_code", None),
514
+ )
515
+ tiers[svc] = "standard"
516
+ return tiers
517
+
518
+ async def get_state(self) -> dict:
519
+ """Return cursors + active incidents (with terminal-state pruning).
520
+
521
+ Pruning rule: incidents in TERMINAL_STATES whose updated_at is older
522
+ than config.terminal_retention_seconds are dropped. Active incidents
523
+ are never pruned regardless of age.
524
+ """
525
+ retention = self.config.terminal_retention_seconds
526
+ return {
527
+ "cursors": self._cursors.to_dict(),
528
+ "incidents": {
529
+ inc_id: incident_context_to_dict(ctx)
530
+ for inc_id, ctx in self._incidents.items()
531
+ if not _is_terminal_and_aged(ctx, retention)
532
+ },
533
+ }