agent-killswitch 0.1.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.
@@ -0,0 +1,313 @@
1
+ """Heartbeat monitor for tracking agent health.
2
+
3
+ Provides a HeartbeatMonitor that tracks periodic heartbeats from agents
4
+ and detects when agents become unresponsive. Designed to work with the
5
+ KillSwitch for automatic agent termination on death detection.
6
+
7
+ Thread-safe: All state is protected by a reentrant lock.
8
+ No external dependencies: Uses only Python stdlib (threading, time).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from datetime import datetime, timezone
18
+
19
+ from .enums import HeartbeatStatus
20
+ from .models import HeartbeatRecord
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class HeartbeatMonitor:
26
+ """Monitor agent health via periodic heartbeats.
27
+
28
+ Agents send periodic heartbeats to indicate they are alive.
29
+ The monitor tracks heartbeats and detects dead agents based
30
+ on a configurable miss threshold.
31
+
32
+ Example::
33
+
34
+ from agent_killswitch import HeartbeatMonitor
35
+
36
+ monitor = HeartbeatMonitor(
37
+ interval_seconds=5.0,
38
+ miss_threshold=3,
39
+ on_dead=lambda agent_id: print(f"DEAD: {agent_id}"),
40
+ )
41
+
42
+ # Start monitoring
43
+ monitor.start("agent-1")
44
+
45
+ # Agent sends heartbeats periodically
46
+ monitor.heartbeat("agent-1")
47
+
48
+ # Check health
49
+ health = monitor.check_health()
50
+ for agent_id, status in health.items():
51
+ print(f"{agent_id}: {status}")
52
+
53
+ # Stop monitoring
54
+ monitor.stop("agent-1")
55
+
56
+ Args:
57
+ interval_seconds: Expected heartbeat interval. Default 5.0s.
58
+ miss_threshold: Number of missed intervals before DEAD. Default 3.
59
+ on_dead: Callback when an agent is detected as dead.
60
+ Signature: (agent_id: str) -> None
61
+ on_suspect: Callback when an agent becomes suspect (missed 1+).
62
+ Signature: (agent_id: str, miss_count: int) -> None
63
+ auto_check_interval: If > 0, starts a background thread that
64
+ checks health periodically. Default 0 (disabled).
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ interval_seconds: float = 5.0,
70
+ miss_threshold: int = 3,
71
+ on_dead: Callable[[str], None] | None = None,
72
+ on_suspect: Callable[[str, int], None] | None = None,
73
+ auto_check_interval: float = 0.0,
74
+ ) -> None:
75
+ self._interval = interval_seconds
76
+ self._miss_threshold = miss_threshold
77
+ self._on_dead = on_dead
78
+ self._on_suspect = on_suspect
79
+ self._lock = threading.RLock()
80
+
81
+ # Agent heartbeat records
82
+ self._records: dict[str, HeartbeatRecord] = {}
83
+
84
+ # Track which agents have already been reported as dead
85
+ # (to avoid repeated callbacks)
86
+ self._dead_reported: set[str] = set()
87
+
88
+ # Background checker
89
+ self._checker_thread: threading.Thread | None = None
90
+ self._checker_stop = threading.Event()
91
+ self._auto_check_interval = auto_check_interval
92
+
93
+ if auto_check_interval > 0:
94
+ self._start_checker()
95
+
96
+ def _start_checker(self) -> None:
97
+ """Start the background health check thread."""
98
+ self._checker_stop.clear()
99
+ self._checker_thread = threading.Thread(
100
+ target=self._checker_loop,
101
+ daemon=True,
102
+ name="killswitch-heartbeat-checker",
103
+ )
104
+ self._checker_thread.start()
105
+
106
+ def _checker_loop(self) -> None:
107
+ """Background loop that checks health periodically."""
108
+ while not self._checker_stop.is_set():
109
+ try:
110
+ self.check_health()
111
+ except Exception as exc:
112
+ logger.error(
113
+ "Heartbeat checker error",
114
+ extra={"error": str(exc)},
115
+ )
116
+ self._checker_stop.wait(self._auto_check_interval)
117
+
118
+ def shutdown(self) -> None:
119
+ """Stop the background checker and clean up."""
120
+ self._checker_stop.set()
121
+ if self._checker_thread is not None:
122
+ self._checker_thread.join(timeout=5.0)
123
+ self._checker_thread = None
124
+
125
+ def start(
126
+ self,
127
+ agent_id: str,
128
+ interval_seconds: float | None = None,
129
+ ) -> None:
130
+ """Start monitoring an agent.
131
+
132
+ Records the initial heartbeat and begins tracking.
133
+
134
+ Args:
135
+ agent_id: Unique identifier of the agent to monitor.
136
+ interval_seconds: Override the default interval for this agent.
137
+ """
138
+ now = datetime.now(timezone.utc)
139
+ record = HeartbeatRecord(
140
+ agent_id=agent_id,
141
+ last_heartbeat=now,
142
+ status=HeartbeatStatus.ALIVE,
143
+ miss_count=0,
144
+ interval_seconds=interval_seconds or self._interval,
145
+ )
146
+
147
+ with self._lock:
148
+ self._records[agent_id] = record
149
+ self._dead_reported.discard(agent_id)
150
+
151
+ logger.info(
152
+ "heartbeat_monitoring_started",
153
+ extra={
154
+ "agent_id": agent_id,
155
+ "interval_seconds": record.interval_seconds,
156
+ },
157
+ )
158
+
159
+ def stop(self, agent_id: str) -> None:
160
+ """Stop monitoring an agent.
161
+
162
+ Args:
163
+ agent_id: The agent to stop monitoring.
164
+ """
165
+ with self._lock:
166
+ self._records.pop(agent_id, None)
167
+ self._dead_reported.discard(agent_id)
168
+
169
+ logger.info("heartbeat_monitoring_stopped", extra={"agent_id": agent_id})
170
+
171
+ def heartbeat(self, agent_id: str) -> bool:
172
+ """Record a heartbeat from an agent.
173
+
174
+ Resets the miss count and marks the agent as ALIVE.
175
+
176
+ Args:
177
+ agent_id: The agent sending the heartbeat.
178
+
179
+ Returns:
180
+ True if the heartbeat was recorded, False if agent not monitored.
181
+ """
182
+ with self._lock:
183
+ record = self._records.get(agent_id)
184
+ if record is None:
185
+ return False
186
+
187
+ record.last_heartbeat = datetime.now(timezone.utc)
188
+ record.miss_count = 0
189
+ record.status = HeartbeatStatus.ALIVE
190
+ self._dead_reported.discard(agent_id)
191
+
192
+ return True
193
+
194
+ def check_health(self) -> dict[str, HeartbeatStatus]:
195
+ """Check health of all monitored agents.
196
+
197
+ Calculates how many intervals have been missed since the last
198
+ heartbeat and updates the status accordingly.
199
+
200
+ Returns:
201
+ Dictionary of agent_id -> HeartbeatStatus.
202
+ """
203
+ time.monotonic()
204
+ now_dt = datetime.now(timezone.utc)
205
+ results: dict[str, HeartbeatStatus] = {}
206
+
207
+ with self._lock:
208
+ for agent_id, record in list(self._records.items()):
209
+ if record.last_heartbeat is None:
210
+ record.status = HeartbeatStatus.UNKNOWN
211
+ results[agent_id] = HeartbeatStatus.UNKNOWN
212
+ continue
213
+
214
+ elapsed = (now_dt - record.last_heartbeat).total_seconds()
215
+ missed = int(elapsed / record.interval_seconds)
216
+
217
+ if missed <= 0:
218
+ record.miss_count = 0
219
+ record.status = HeartbeatStatus.ALIVE
220
+ else:
221
+ record.miss_count = missed
222
+
223
+ if missed >= self._miss_threshold:
224
+ record.status = HeartbeatStatus.DEAD
225
+
226
+ # Fire dead callback (once per death event)
227
+ if agent_id not in self._dead_reported:
228
+ self._dead_reported.add(agent_id)
229
+ if self._on_dead is not None:
230
+ try:
231
+ self._on_dead(agent_id)
232
+ except Exception as exc:
233
+ logger.error(
234
+ "on_dead callback error",
235
+ extra={
236
+ "agent_id": agent_id,
237
+ "error": str(exc),
238
+ },
239
+ )
240
+ else:
241
+ record.status = HeartbeatStatus.SUSPECT
242
+
243
+ # Fire suspect callback
244
+ if self._on_suspect is not None:
245
+ try:
246
+ self._on_suspect(agent_id, missed)
247
+ except Exception as exc:
248
+ logger.error(
249
+ "on_suspect callback error",
250
+ extra={
251
+ "agent_id": agent_id,
252
+ "error": str(exc),
253
+ },
254
+ )
255
+
256
+ results[agent_id] = record.status
257
+
258
+ return results
259
+
260
+ def get_dead_agents(self) -> list[str]:
261
+ """Return IDs of all agents currently detected as dead.
262
+
263
+ Returns:
264
+ List of dead agent IDs.
265
+ """
266
+ health = self.check_health()
267
+ return [agent_id for agent_id, status in health.items() if status == HeartbeatStatus.DEAD]
268
+
269
+ def get_record(self, agent_id: str) -> HeartbeatRecord | None:
270
+ """Get the heartbeat record for an agent.
271
+
272
+ Args:
273
+ agent_id: The agent to look up.
274
+
275
+ Returns:
276
+ The HeartbeatRecord, or None if not monitored.
277
+ """
278
+ with self._lock:
279
+ record = self._records.get(agent_id)
280
+ if record is None:
281
+ return None
282
+ # Return a copy to prevent external mutation
283
+ return HeartbeatRecord(
284
+ agent_id=record.agent_id,
285
+ last_heartbeat=record.last_heartbeat,
286
+ status=record.status,
287
+ miss_count=record.miss_count,
288
+ interval_seconds=record.interval_seconds,
289
+ )
290
+
291
+ def get_all_records(self) -> dict[str, HeartbeatRecord]:
292
+ """Get heartbeat records for all monitored agents.
293
+
294
+ Returns:
295
+ Dictionary of agent_id -> HeartbeatRecord (copies).
296
+ """
297
+ with self._lock:
298
+ return {
299
+ agent_id: HeartbeatRecord(
300
+ agent_id=rec.agent_id,
301
+ last_heartbeat=rec.last_heartbeat,
302
+ status=rec.status,
303
+ miss_count=rec.miss_count,
304
+ interval_seconds=rec.interval_seconds,
305
+ )
306
+ for agent_id, rec in self._records.items()
307
+ }
308
+
309
+ @property
310
+ def monitored_count(self) -> int:
311
+ """Number of agents currently being monitored."""
312
+ with self._lock:
313
+ return len(self._records)