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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|