cloudagent-daemon 0.2.1__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,534 @@
1
+ """Wave C #3 — health self-check used by the ``doctor`` CLI subcommand.
2
+
3
+ Produces a single :class:`DoctorReport` dataclass + a human-readable
4
+ renderer. Inputs come from three sources:
5
+
6
+ * **Daemon process** — ``daemon.pid`` file + PID liveness. Doesn't
7
+ require a running daemon to function (the doctor command is most
8
+ useful when a daemon is misbehaving).
9
+ * **On-disk state** — ``state.json`` (last_seq, processed counts)
10
+ and ``~/.cloudagent/agents/<id>/.meta/`` files (workspace count,
11
+ outbox depth, last-spawn outcome).
12
+ * **Live metrics** — optional, scraped from the running daemon's
13
+ metrics socket. Falls back gracefully when the socket isn't
14
+ available (e.g. pre-Wave-C-#3 daemon, or process down).
15
+
16
+ All checks are stdlib-only — no httpx, no aiohttp — so the doctor
17
+ runs on a minimal install.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import socket
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+ from typing import Iterable
29
+
30
+ from cloudagent_daemon.config import DaemonState
31
+ from cloudagent_daemon.metrics_server import DEFAULT_METRICS_SOCKET_PATH
32
+ from cloudagent_daemon.workspace import Workspace
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Report dataclasses
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ @dataclass
41
+ class AgentSnapshot:
42
+ """One row of the per-agent state table."""
43
+
44
+ agent_id: str
45
+ has_meta_dir: bool
46
+ last_spawn_rc: int | None
47
+ last_spawn_session_id: str | None
48
+ last_spawn_ts: str | None
49
+ pending_spawn_lines: int
50
+ pending_received_without_done: int
51
+
52
+
53
+ @dataclass
54
+ class DoctorReport:
55
+ """Top-level health summary, JSON-able + human-renderable.
56
+
57
+ All numeric / optional-string fields default to ``None`` /
58
+ ``False`` so a missing-data path renders as "—" rather than
59
+ crashing the renderer.
60
+ """
61
+
62
+ daemon_pid: int | None = None
63
+ daemon_pid_alive: bool = False
64
+ server_url: str | None = None
65
+ host_id: str | None = None
66
+ state_path: str | None = None
67
+ last_seq: int | None = None
68
+ processed_count: int | None = None
69
+ workspace_root: str | None = None
70
+ agents: list[AgentSnapshot] = field(default_factory=list)
71
+ total_pending_received_without_done: int = 0
72
+ profile: str | None = None
73
+ metrics_socket_path: str | None = None
74
+ metrics_available: bool = False
75
+ metrics_text_excerpt: list[str] = field(default_factory=list)
76
+ metrics_uptime_seconds: float | None = None
77
+ sse_last_event_age_seconds: float | None = None
78
+ sse_reconnects_total: int | None = None
79
+ sse_active_watchdog_fires_total: int | None = None
80
+ deliver_frames_total: int | None = None
81
+ spawn_completed_idle: int | None = None
82
+ spawn_completed_crashed: int | None = None
83
+ spawn_completed_timeout: int | None = None
84
+ spawn_fallback_send_message_total: int | None = None
85
+ outbox_stuck_count: int | None = None
86
+ agents_bound: int | None = None
87
+ notes: list[str] = field(default_factory=list)
88
+
89
+ def health_signals(self) -> list[tuple[str, str, str]]:
90
+ """Return ``[(label, status, value), ...]`` for the renderer.
91
+
92
+ Status values:
93
+ * ``"OK"`` — green
94
+ * ``"WARN"`` — yellow
95
+ * ``"ALERT"`` — red
96
+ * ``"—"`` — data missing / not applicable
97
+ """
98
+ rows: list[tuple[str, str, str]] = []
99
+
100
+ if self.daemon_pid_alive:
101
+ rows.append(("daemon", "OK", f"running pid={self.daemon_pid}"))
102
+ elif self.daemon_pid is not None:
103
+ rows.append(("daemon", "ALERT", f"stale pid file (pid={self.daemon_pid})"))
104
+ else:
105
+ rows.append(("daemon", "ALERT", "not running (no PID file)"))
106
+
107
+ # SSE liveness from metrics.
108
+ if self.sse_last_event_age_seconds is None:
109
+ rows.append(("sse", "—", "no metrics (daemon down or pre-Wave-C-#3)"))
110
+ else:
111
+ age = self.sse_last_event_age_seconds
112
+ status = (
113
+ "OK" if age < 60 else ("WARN" if age < 180 else "ALERT")
114
+ )
115
+ rows.append(("sse", status, f"last event {age:.0f}s ago"))
116
+
117
+ if self.total_pending_received_without_done == 0:
118
+ rows.append(("outbox", "OK", "0 pending"))
119
+ elif self.outbox_stuck_count and self.outbox_stuck_count > 0:
120
+ rows.append(
121
+ (
122
+ "outbox",
123
+ "ALERT",
124
+ f"{self.total_pending_received_without_done} pending, "
125
+ f"{self.outbox_stuck_count} STUCK",
126
+ )
127
+ )
128
+ else:
129
+ rows.append(
130
+ (
131
+ "outbox",
132
+ "WARN",
133
+ f"{self.total_pending_received_without_done} pending "
134
+ f"(within priority thresholds)",
135
+ )
136
+ )
137
+
138
+ # Spawn outcomes (cumulative).
139
+ idle = self.spawn_completed_idle or 0
140
+ crashed = self.spawn_completed_crashed or 0
141
+ timeout = self.spawn_completed_timeout or 0
142
+ total = idle + crashed + timeout
143
+ if total == 0 and not self.metrics_available:
144
+ rows.append(("wakes", "—", "no metrics yet"))
145
+ else:
146
+ fail = crashed + timeout
147
+ rate = (fail / total) if total else 0.0
148
+ status = (
149
+ "OK" if rate < 0.05
150
+ else ("WARN" if rate < 0.20 else "ALERT")
151
+ )
152
+ rows.append(
153
+ (
154
+ "wakes",
155
+ status,
156
+ f"{total} total ({idle} idle / {crashed} crashed "
157
+ f"/ {timeout} timeout)",
158
+ )
159
+ )
160
+
161
+ # Daemon uptime.
162
+ if self.metrics_uptime_seconds is None:
163
+ rows.append(("uptime", "—", "—"))
164
+ else:
165
+ s = int(self.metrics_uptime_seconds)
166
+ human = (
167
+ f"{s}s" if s < 60
168
+ else f"{s // 60}m {s % 60}s" if s < 3600
169
+ else f"{s // 3600}h {(s % 3600) // 60}m"
170
+ )
171
+ rows.append(("uptime", "OK", human))
172
+
173
+ return rows
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Sub-collectors
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _pid_alive(pid: int) -> bool:
182
+ try:
183
+ os.kill(pid, 0)
184
+ except ProcessLookupError:
185
+ return False
186
+ except PermissionError:
187
+ return True
188
+ return True
189
+
190
+
191
+ def _collect_daemon_process(report: DoctorReport, profile: str) -> None:
192
+ from .paths import profile_pid_path
193
+
194
+ pid_path = profile_pid_path(profile)
195
+ if not pid_path.exists():
196
+ return
197
+ try:
198
+ pid = int(pid_path.read_text().strip())
199
+ except (ValueError, OSError):
200
+ return
201
+ report.daemon_pid = pid
202
+ report.daemon_pid_alive = _pid_alive(pid)
203
+
204
+
205
+ def _collect_state(report: DoctorReport, profile: str) -> None:
206
+ state_path = DaemonState.default_path(profile)
207
+ report.state_path = str(state_path)
208
+ if not state_path.exists():
209
+ return
210
+ try:
211
+ state = DaemonState.load_default(profile)
212
+ except (ValueError, OSError):
213
+ report.notes.append(f"state.json corrupt: {state_path}")
214
+ return
215
+ report.last_seq = state.last_seq
216
+ report.processed_count = len(state.processed_message_ids or [])
217
+
218
+
219
+ def _collect_agents(report: DoctorReport, workspace: Workspace) -> None:
220
+ report.workspace_root = str(workspace.root)
221
+ if not workspace.root.exists():
222
+ return
223
+ for agent_dir in sorted(workspace.root.iterdir()):
224
+ if not agent_dir.is_dir():
225
+ continue
226
+ agent_id = agent_dir.name
227
+ meta = agent_dir / ".meta"
228
+ has_meta = meta.exists()
229
+ last_spawn_path = meta / "last-spawn.json"
230
+ last_rc: int | None = None
231
+ last_sid: str | None = None
232
+ last_ts: str | None = None
233
+ if last_spawn_path.exists():
234
+ try:
235
+ data = json.loads(last_spawn_path.read_text())
236
+ last_rc = data.get("exit_code")
237
+ last_sid = data.get("session_id")
238
+ last_ts = data.get("ts")
239
+ except (json.JSONDecodeError, OSError):
240
+ report.notes.append(
241
+ f"last-spawn.json unreadable on {agent_id}"
242
+ )
243
+
244
+ pending_path = meta / "pending-spawn.jsonl"
245
+ line_count = 0
246
+ received_minus_done = 0
247
+ if pending_path.exists():
248
+ received_ids: set[str] = set()
249
+ done_ids: set[str] = set()
250
+ try:
251
+ for raw in pending_path.read_text(encoding="utf-8").splitlines():
252
+ raw = raw.strip()
253
+ if not raw:
254
+ continue
255
+ line_count += 1
256
+ try:
257
+ entry = json.loads(raw)
258
+ except (TypeError, ValueError):
259
+ continue
260
+ msg_id = str(entry.get("msg_id") or "")
261
+ if not msg_id:
262
+ continue
263
+ if entry.get("event") == "received":
264
+ received_ids.add(msg_id)
265
+ elif entry.get("event") == "done":
266
+ done_ids.add(msg_id)
267
+ except OSError:
268
+ report.notes.append(
269
+ f"pending-spawn.jsonl unreadable on {agent_id}"
270
+ )
271
+ received_minus_done = len(received_ids - done_ids)
272
+ report.agents.append(
273
+ AgentSnapshot(
274
+ agent_id=agent_id,
275
+ has_meta_dir=has_meta,
276
+ last_spawn_rc=last_rc,
277
+ last_spawn_session_id=last_sid,
278
+ last_spawn_ts=last_ts,
279
+ pending_spawn_lines=line_count,
280
+ pending_received_without_done=received_minus_done,
281
+ )
282
+ )
283
+ report.total_pending_received_without_done += received_minus_done
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Metrics fetch (best-effort)
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ def _fetch_metrics_text(socket_path: Path, *, timeout: float = 2.0) -> str | None:
292
+ """Connect to the metrics unix socket and return the HTTP body.
293
+
294
+ Returns ``None`` on any failure — the doctor falls back to
295
+ on-disk-only signals. We use the stdlib ``socket`` module (sync)
296
+ so the doctor can run from a plain CLI without spinning up an
297
+ asyncio loop just for the scrape.
298
+ """
299
+ if not socket_path.exists():
300
+ return None
301
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
302
+ sock.settimeout(timeout)
303
+ try:
304
+ sock.connect(str(socket_path))
305
+ sock.sendall(b"GET /metrics HTTP/1.1\r\nHost: localhost\r\n\r\n")
306
+ chunks: list[bytes] = []
307
+ deadline = time.monotonic() + timeout
308
+ while True:
309
+ remaining = deadline - time.monotonic()
310
+ if remaining <= 0:
311
+ break
312
+ try:
313
+ sock.settimeout(remaining)
314
+ chunk = sock.recv(8192)
315
+ except socket.timeout:
316
+ break
317
+ if not chunk:
318
+ break
319
+ chunks.append(chunk)
320
+ raw = b"".join(chunks)
321
+ # Split header from body — accept either CRLF*2 or LF*2.
322
+ sep = b"\r\n\r\n"
323
+ if sep not in raw:
324
+ sep = b"\n\n"
325
+ if sep not in raw:
326
+ return raw.decode("utf-8", errors="replace")
327
+ _, body = raw.split(sep, 1)
328
+ return body.decode("utf-8", errors="replace")
329
+ except (OSError, socket.timeout):
330
+ return None
331
+ finally:
332
+ try:
333
+ sock.close()
334
+ except OSError:
335
+ pass
336
+
337
+
338
+ def _apply_metrics(report: DoctorReport, text: str) -> None:
339
+ """Parse the Prometheus body and copy fields onto ``report``.
340
+
341
+ Tolerant: any unrecognised line is preserved in
342
+ ``metrics_text_excerpt`` (capped at 20 lines so the doctor output
343
+ doesn't explode). Standard counter/gauge names are mapped onto
344
+ typed fields for the renderer.
345
+ """
346
+ report.metrics_available = True
347
+ counter_idle = 0
348
+ counter_crashed = 0
349
+ counter_timeout = 0
350
+ for line in text.splitlines():
351
+ line = line.strip()
352
+ if not line or line.startswith("#"):
353
+ continue
354
+ # Each sample is ``name{labels?} value`` (optional ``timestamp``).
355
+ name_section, _, rest = line.partition(" ")
356
+ try:
357
+ value_token = rest.split()[0]
358
+ value_float = float(value_token)
359
+ except (IndexError, ValueError):
360
+ continue
361
+ if "{" in name_section:
362
+ name, label_part = name_section.split("{", 1)
363
+ label_part = label_part.rstrip("}")
364
+ else:
365
+ name = name_section
366
+ label_part = ""
367
+
368
+ labels: dict[str, str] = {}
369
+ if label_part:
370
+ for kv in label_part.split(","):
371
+ if "=" in kv:
372
+ k, v = kv.split("=", 1)
373
+ labels[k.strip()] = v.strip().strip('"')
374
+
375
+ if name == "cloudagent_daemon_uptime_seconds":
376
+ report.metrics_uptime_seconds = value_float
377
+ elif name == "cloudagent_daemon_sse_last_event_age_seconds":
378
+ report.sse_last_event_age_seconds = value_float
379
+ elif name == "cloudagent_daemon_sse_reconnects_total":
380
+ report.sse_reconnects_total = int(value_float)
381
+ elif name == "cloudagent_daemon_sse_active_watchdog_fires_total":
382
+ report.sse_active_watchdog_fires_total = int(value_float)
383
+ elif name == "cloudagent_daemon_deliver_frames_total":
384
+ report.deliver_frames_total = int(value_float)
385
+ elif name == "cloudagent_daemon_spawn_completed_total":
386
+ status = labels.get("status")
387
+ if status == "idle":
388
+ counter_idle += int(value_float)
389
+ elif status == "crashed":
390
+ counter_crashed += int(value_float)
391
+ elif status == "timeout":
392
+ counter_timeout += int(value_float)
393
+ elif name == "cloudagent_daemon_spawn_fallback_send_message_total":
394
+ report.spawn_fallback_send_message_total = int(value_float)
395
+ elif name == "cloudagent_daemon_outbox_stuck":
396
+ report.outbox_stuck_count = int(value_float)
397
+ elif name == "cloudagent_daemon_agents_bound":
398
+ report.agents_bound = int(value_float)
399
+ report.spawn_completed_idle = counter_idle
400
+ report.spawn_completed_crashed = counter_crashed
401
+ report.spawn_completed_timeout = counter_timeout
402
+
403
+ # Show first 20 non-blank lines as a raw excerpt for operators
404
+ # who want to see the wire format.
405
+ excerpt: list[str] = []
406
+ for ln in text.splitlines():
407
+ if not ln.strip() or ln.startswith("#"):
408
+ continue
409
+ excerpt.append(ln)
410
+ if len(excerpt) >= 20:
411
+ break
412
+ report.metrics_text_excerpt = excerpt
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Public entry points
417
+ # ---------------------------------------------------------------------------
418
+
419
+
420
+ def collect_report(
421
+ *,
422
+ workspace: Workspace | None = None,
423
+ metrics_socket_path: Path | None = None,
424
+ profile: str | None = None,
425
+ ) -> DoctorReport:
426
+ """Walk the daemon's local state + scrape live metrics. Synchronous.
427
+
428
+ Workspace + metrics socket are injectable for tests; defaults
429
+ match the real on-disk layout under ``~/.cloudagent``. *profile*
430
+ selects which profile's pid / state / config to read; default
431
+ profile reads the legacy paths so existing callers don't change.
432
+ """
433
+ from .paths import resolve_profile
434
+
435
+ profile_name = resolve_profile(profile)
436
+ report = DoctorReport()
437
+ report.profile = profile_name
438
+ workspace = workspace or Workspace(profile=profile_name)
439
+ metrics_socket_path = metrics_socket_path or DEFAULT_METRICS_SOCKET_PATH
440
+ report.metrics_socket_path = str(metrics_socket_path)
441
+
442
+ # Best-effort daemon config — only used to display server_url +
443
+ # host_id. Missing config is non-fatal.
444
+ try:
445
+ from cloudagent_daemon.config import DaemonConfig
446
+
447
+ cfg = DaemonConfig.load_default(profile_name)
448
+ report.server_url = cfg.server_url
449
+ report.host_id = cfg.host_id
450
+ except (FileNotFoundError, ValueError, OSError):
451
+ report.notes.append("daemon not configured (run `cloudagent-daemon login`)")
452
+
453
+ _collect_daemon_process(report, profile_name)
454
+ _collect_state(report, profile_name)
455
+ _collect_agents(report, workspace)
456
+ metrics_text = _fetch_metrics_text(metrics_socket_path)
457
+ if metrics_text is not None:
458
+ _apply_metrics(report, metrics_text)
459
+ return report
460
+
461
+
462
+ def render_report(report: DoctorReport) -> str:
463
+ """Human-readable doctor output (the ``cloudagent-daemon doctor`` body)."""
464
+ lines: list[str] = []
465
+ lines.append("=" * 60)
466
+ lines.append("cloudagent-daemon doctor")
467
+ lines.append(datetime.now(UTC).isoformat())
468
+ lines.append("=" * 60)
469
+ lines.append("")
470
+
471
+ lines.append("--- identity ---")
472
+ lines.append(f" profile: {report.profile or '—'}")
473
+ lines.append(f" server_url: {report.server_url or '—'}")
474
+ lines.append(f" host_id: {report.host_id or '—'}")
475
+ lines.append(f" workspace: {report.workspace_root or '—'}")
476
+ lines.append(f" state.json: {report.state_path or '—'}")
477
+ if report.last_seq is not None:
478
+ lines.append(f" last_seq: {report.last_seq}")
479
+ if report.processed_count is not None:
480
+ lines.append(f" processed_msgs:{report.processed_count}")
481
+ lines.append("")
482
+
483
+ lines.append("--- health ---")
484
+ for label, status, value in report.health_signals():
485
+ lines.append(f" {label:<10} [{status:^5}] {value}")
486
+ if report.agents_bound is not None:
487
+ lines.append(f" agents_bound[ OK ] {report.agents_bound} (per /metrics)")
488
+ if report.sse_reconnects_total is not None:
489
+ lines.append(
490
+ f" sse_reconnects total {report.sse_reconnects_total} "
491
+ f"(watchdog fires: {report.sse_active_watchdog_fires_total or 0})"
492
+ )
493
+ if report.spawn_fallback_send_message_total:
494
+ lines.append(
495
+ f" fallback_send_message: {report.spawn_fallback_send_message_total} "
496
+ "(1k #63 Layer B — non-zero means Layer A template slipped)"
497
+ )
498
+ lines.append("")
499
+
500
+ if report.agents:
501
+ lines.append("--- agents ---")
502
+ for a in report.agents:
503
+ tail = (
504
+ f"rc={a.last_spawn_rc} sid={a.last_spawn_session_id}"
505
+ if a.last_spawn_rc is not None
506
+ else "(no last-spawn)"
507
+ )
508
+ pending_tail = (
509
+ f"pending={a.pending_received_without_done}"
510
+ if a.pending_received_without_done
511
+ else ""
512
+ )
513
+ lines.append(f" {a.agent_id}: {tail} {pending_tail}".rstrip())
514
+ lines.append("")
515
+
516
+ if not report.metrics_available:
517
+ lines.append(
518
+ f"--- /metrics --- unavailable ({report.metrics_socket_path})"
519
+ )
520
+ else:
521
+ lines.append("--- /metrics excerpt (first 20 series) ---")
522
+ for ln in report.metrics_text_excerpt:
523
+ lines.append(f" {ln}")
524
+ lines.append("")
525
+
526
+ if report.notes:
527
+ lines.append("--- notes ---")
528
+ for n in report.notes:
529
+ lines.append(f" * {n}")
530
+
531
+ return "\n".join(lines) + "\n"
532
+
533
+
534
+ __all__ = ["DoctorReport", "AgentSnapshot", "collect_report", "render_report"]