cloudagent-daemon 0.2.4__tar.gz → 0.2.6__tar.gz
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-0.2.4 → cloudagent_daemon-0.2.6}/PKG-INFO +1 -1
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/pyproject.toml +1 -1
- cloudagent_daemon-0.2.6/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/main_loop.py +8 -8
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/service.py +60 -19
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/spawn_outbox.py +128 -15
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/spawn_queue.py +49 -0
- cloudagent_daemon-0.2.4/src/cloudagent_daemon/__init__.py +0 -1
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/.gitignore +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/LICENSE +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/README.md +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/claude_runner.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/cli.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/config.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/frame_dispatch.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/http_outbound.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/paths.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/protocol.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/ws_client.py +0 -0
- {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
|
|
5
5
|
Project-URL: Homepage, https://cloudagent.chat
|
|
6
6
|
Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cloudagent-daemon"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
|
|
9
9
|
authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.6"
|
|
@@ -51,14 +51,14 @@ _HEARTBEAT_INTERVAL_S = HEARTBEAT_INTERVAL_SECONDS
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
# Wave C #2 — outbox stuck watchdog cadence. Scans every agent's
|
|
54
|
-
# ``pending-spawn.jsonl`` for ``received
|
|
55
|
-
# older than their per-priority
|
|
56
|
-
# :data:`spawn_outbox.STUCK_THRESHOLDS_SECONDS`). The
|
|
57
|
-
# escalates to ``SystemExit(1)`` so launchd respawns
|
|
58
|
-
# boot-replay path re-enqueues. 60s scan period
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
54
|
+
# ``pending-spawn.jsonl`` for ``received`` entries with neither a
|
|
55
|
+
# ``done`` nor a recent ``heartbeat``, older than their per-priority
|
|
56
|
+
# threshold (see :data:`spawn_outbox.STUCK_THRESHOLDS_SECONDS`). The
|
|
57
|
+
# first stuck spawn escalates to ``SystemExit(1)`` so launchd respawns
|
|
58
|
+
# the daemon and the boot-replay path re-enqueues. 60s scan period
|
|
59
|
+
# catches a genuinely stuck spawn within ~1 min of it crossing the
|
|
60
|
+
# threshold, slack enough that the scan cost (~1 ms per agent per pass)
|
|
61
|
+
# is negligible.
|
|
62
62
|
OUTBOX_WATCHDOG_INTERVAL_SECONDS = 60.0
|
|
63
63
|
|
|
64
64
|
|
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
import platform
|
|
18
18
|
import shutil
|
|
19
19
|
import subprocess
|
|
20
|
+
import time
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
22
23
|
from .paths import (
|
|
@@ -200,29 +201,69 @@ def install_macos(
|
|
|
200
201
|
plist_path.chmod(0o600)
|
|
201
202
|
|
|
202
203
|
uid = os.getuid()
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
204
|
+
domain = f"gui/{uid}"
|
|
205
|
+
service_target = f"{domain}/{label}"
|
|
206
|
+
_launchd_reinstall(domain, service_target, plist_path)
|
|
207
|
+
return plist_path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _launchd_reinstall(
|
|
211
|
+
domain: str, service_target: str, plist_path: Path
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Reinstall a launchd job: bootout → wait → bootstrap → kickstart.
|
|
214
|
+
|
|
215
|
+
Reinstall-safe + race-safe. Two problems this handles:
|
|
216
|
+
|
|
217
|
+
1. A plain ``bootstrap`` is a no-op when the service is already
|
|
218
|
+
loaded — launchd keeps the OLD in-memory plist and never re-reads
|
|
219
|
+
the file. So we ``bootout`` first to unload any loaded instance.
|
|
220
|
+
|
|
221
|
+
2. ``bootout`` is *asynchronous*: launchctl returns before launchd
|
|
222
|
+
has finished tearing the job down. A ``bootstrap`` fired
|
|
223
|
+
immediately races the still-unloading job and fails with
|
|
224
|
+
``Input/output error`` (5). So after bootout we POLL
|
|
225
|
+
``launchctl print`` until the job is genuinely gone, then
|
|
226
|
+
bootstrap — with a few retries for slow teardown.
|
|
227
|
+
|
|
228
|
+
Raises RuntimeError if bootstrap never succeeds (cli surfaces it as
|
|
229
|
+
a clean error instead of the old silent "✓ Installed" lie).
|
|
230
|
+
"""
|
|
231
|
+
subprocess.run(["launchctl", "bootout", service_target], check=False)
|
|
232
|
+
|
|
233
|
+
# Poll until the job is no longer registered (``print`` exits
|
|
234
|
+
# non-zero). ~5s budget — teardown is normally well under 1s.
|
|
235
|
+
for _ in range(50):
|
|
236
|
+
probe = subprocess.run(
|
|
237
|
+
["launchctl", "print", service_target],
|
|
238
|
+
capture_output=True,
|
|
239
|
+
check=False,
|
|
240
|
+
)
|
|
241
|
+
if probe.returncode != 0:
|
|
242
|
+
break
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
|
|
245
|
+
last_err = ""
|
|
246
|
+
for _ in range(5):
|
|
247
|
+
result = subprocess.run(
|
|
248
|
+
["launchctl", "bootstrap", domain, str(plist_path)],
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
check=False,
|
|
252
|
+
)
|
|
253
|
+
if result.returncode == 0:
|
|
254
|
+
break
|
|
255
|
+
last_err = (result.stderr or result.stdout or "").strip()
|
|
256
|
+
time.sleep(0.3)
|
|
257
|
+
else:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"launchctl bootstrap failed after retries: {last_err or 'unknown'}"
|
|
260
|
+
)
|
|
261
|
+
|
|
220
262
|
# Kickstart -k forces an immediate (re)start.
|
|
221
263
|
subprocess.run(
|
|
222
|
-
["launchctl", "kickstart", "-k",
|
|
264
|
+
["launchctl", "kickstart", "-k", service_target],
|
|
223
265
|
check=False,
|
|
224
266
|
)
|
|
225
|
-
return plist_path
|
|
226
267
|
|
|
227
268
|
|
|
228
269
|
def uninstall_macos(profile: str | None = None) -> bool:
|
|
@@ -25,11 +25,20 @@ This module makes spawn-acceptance durable:
|
|
|
25
25
|
unresolved received entries. Triggered automatically after each
|
|
26
26
|
done write once the file exceeds ``compact_threshold`` lines.
|
|
27
27
|
|
|
28
|
-
File format — newline-delimited JSON, one entry per line.
|
|
28
|
+
File format — newline-delimited JSON, one entry per line. Three shapes:
|
|
29
29
|
|
|
30
30
|
``{"event": "received", "msg_id": str, "wake_id": str | null,
|
|
31
31
|
"ts": iso, "payload": {...}}``
|
|
32
32
|
``{"event": "done", "msg_id": str, "ts": iso, "ok": bool}``
|
|
33
|
+
``{"event": "heartbeat", "msg_id": str, "ts": iso}``
|
|
34
|
+
|
|
35
|
+
The ``heartbeat`` shape is appended by :class:`SpawnQueue` every
|
|
36
|
+
``HEARTBEAT_INTERVAL_SECONDS`` for as long as it is actively running a
|
|
37
|
+
spawn. :meth:`SpawnOutbox.find_stuck` ages each entry from its latest
|
|
38
|
+
sign of life (``received`` OR ``heartbeat``), so a legitimately long
|
|
39
|
+
wake — a 30-60+ min coding spawn — is not mistaken for a hung one.
|
|
40
|
+
(Bug: the outbox-watchdog self-killed the daemon mid-spawn because a
|
|
41
|
+
coding wake outran the flat 30-min threshold — 2026-05-20.)
|
|
33
42
|
|
|
34
43
|
Concurrency: a per-agent :class:`asyncio.Lock` serialises writes /
|
|
35
44
|
compaction. ``find_pending`` runs on boot before any writer starts so
|
|
@@ -65,12 +74,27 @@ DEFAULT_COMPACT_THRESHOLD = 200
|
|
|
65
74
|
# Wave C #2 — outbox-stuck self-kill thresholds
|
|
66
75
|
# ---------------------------------------------------------------------------
|
|
67
76
|
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
77
|
+
# A spawn counts as "stuck" when its ``received`` entry has had no
|
|
78
|
+
# ``done`` AND no ``heartbeat`` for longer than the threshold below.
|
|
79
|
+
# :class:`SpawnQueue` appends a ``heartbeat`` every
|
|
80
|
+
# :data:`HEARTBEAT_INTERVAL_SECONDS` for as long as it is actively
|
|
81
|
+
# running the spawn, and :meth:`find_stuck` ages each entry from its
|
|
82
|
+
# *latest sign of life* (newest of received / heartbeat) — NOT from
|
|
83
|
+
# ``received`` alone.
|
|
84
|
+
#
|
|
85
|
+
# Why: a coding wake legitimately runs 30-60+ min. Ageing from
|
|
86
|
+
# ``received`` made the flat 30-min default fire mid-spawn, the daemon
|
|
87
|
+
# ``SystemExit``-ed, launchd respawned, boot-replay re-enqueued — and
|
|
88
|
+
# the agent was guillotined every 30 min, never finishing (the
|
|
89
|
+
# 2026-05-20 "Dev never reports milestone 2" incident). With heartbeat
|
|
90
|
+
# ageing a healthy spawn keeps its age near zero however long it runs.
|
|
91
|
+
#
|
|
92
|
+
# So the threshold now bounds "how long with NO heartbeat before we
|
|
93
|
+
# treat the spawn as lost" — it only fires when SpawnQueue is no longer
|
|
94
|
+
# running the spawn at all (drain task died / spawn lost before
|
|
95
|
+
# enqueue). 2h on the ``None`` (default) bucket is a deliberately
|
|
96
|
+
# conservative backstop: a healthy spawn heartbeats every 60s so never
|
|
97
|
+
# nears it, and a real daemon crash is caught far sooner by boot-replay.
|
|
74
98
|
#
|
|
75
99
|
# Keys are the lowercase stringified priority values; missing /
|
|
76
100
|
# unrecognised priorities use the ``None`` bucket. main_loop's
|
|
@@ -80,9 +104,15 @@ STUCK_THRESHOLDS_SECONDS: dict[str | None, float] = {
|
|
|
80
104
|
"p0": 5 * 60.0,
|
|
81
105
|
"p1": 15 * 60.0,
|
|
82
106
|
"p2": 60 * 60.0,
|
|
83
|
-
None:
|
|
107
|
+
None: 120 * 60.0,
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
# How often :class:`SpawnQueue` appends a ``heartbeat`` entry while a
|
|
111
|
+
# spawn is in flight. Must stay comfortably below the smallest STUCK
|
|
112
|
+
# threshold (``p0`` = 5 min) so a healthy spawn never drifts into
|
|
113
|
+
# "stuck" between two heartbeats.
|
|
114
|
+
HEARTBEAT_INTERVAL_SECONDS: float = 60.0
|
|
115
|
+
|
|
86
116
|
|
|
87
117
|
@dataclass(frozen=True)
|
|
88
118
|
class StuckSpawn:
|
|
@@ -226,6 +256,42 @@ class SpawnOutbox:
|
|
|
226
256
|
ok,
|
|
227
257
|
)
|
|
228
258
|
|
|
259
|
+
async def record_heartbeat(self, agent_id: str, msg_id: str) -> None:
|
|
260
|
+
"""Append a ``heartbeat`` event — proof the spawn is still running.
|
|
261
|
+
|
|
262
|
+
Called periodically by :class:`SpawnQueue` while it is actively
|
|
263
|
+
running a spawn (cadence :data:`HEARTBEAT_INTERVAL_SECONDS`).
|
|
264
|
+
:meth:`find_stuck` ages each pending entry from the newest of its
|
|
265
|
+
``received`` / ``heartbeat`` timestamps, so a long-but-healthy
|
|
266
|
+
wake is not mistaken for a hung spawn. The caller treats this as
|
|
267
|
+
best-effort — a transient write error only shortens one
|
|
268
|
+
heartbeat gap — but the error still propagates so the caller can
|
|
269
|
+
log it.
|
|
270
|
+
"""
|
|
271
|
+
agent_id = str(agent_id or "")
|
|
272
|
+
msg_id = str(msg_id or "")
|
|
273
|
+
if not agent_id or not msg_id:
|
|
274
|
+
return
|
|
275
|
+
path = self._path_for(agent_id)
|
|
276
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
line = json.dumps(
|
|
278
|
+
{
|
|
279
|
+
"event": "heartbeat",
|
|
280
|
+
"msg_id": msg_id,
|
|
281
|
+
"ts": self._now_iso(),
|
|
282
|
+
},
|
|
283
|
+
ensure_ascii=False,
|
|
284
|
+
)
|
|
285
|
+
async with self._lock_for(agent_id):
|
|
286
|
+
with path.open("a", encoding="utf-8") as f:
|
|
287
|
+
f.write(line + "\n")
|
|
288
|
+
f.flush()
|
|
289
|
+
logger.debug(
|
|
290
|
+
"spawn_outbox: recorded heartbeat agent=%s msg=%s",
|
|
291
|
+
agent_id,
|
|
292
|
+
msg_id,
|
|
293
|
+
)
|
|
294
|
+
|
|
229
295
|
async def find_pending(self) -> list[dict[str, Any]]:
|
|
230
296
|
"""Scan every agent's jsonl for received-without-done entries.
|
|
231
297
|
|
|
@@ -314,6 +380,7 @@ class SpawnOutbox:
|
|
|
314
380
|
continue
|
|
315
381
|
received: dict[str, dict[str, Any]] = {}
|
|
316
382
|
done_ids: set[str] = set()
|
|
383
|
+
heartbeats: dict[str, str] = {} # msg_id → latest heartbeat ts
|
|
317
384
|
try:
|
|
318
385
|
lines = jsonl.read_text(encoding="utf-8").splitlines()
|
|
319
386
|
except OSError:
|
|
@@ -334,6 +401,11 @@ class SpawnOutbox:
|
|
|
334
401
|
received[msg_id] = entry
|
|
335
402
|
elif event == "done":
|
|
336
403
|
done_ids.add(msg_id)
|
|
404
|
+
elif event == "heartbeat":
|
|
405
|
+
# jsonl is append-ordered → the last write wins.
|
|
406
|
+
hb_ts = str(entry.get("ts") or "")
|
|
407
|
+
if hb_ts:
|
|
408
|
+
heartbeats[msg_id] = hb_ts
|
|
337
409
|
for msg_id, entry in received.items():
|
|
338
410
|
if msg_id in done_ids:
|
|
339
411
|
continue
|
|
@@ -351,7 +423,27 @@ class SpawnOutbox:
|
|
|
351
423
|
# ISO strings but tests may construct fixtures differently.
|
|
352
424
|
if received_dt.tzinfo is None:
|
|
353
425
|
received_dt = received_dt.replace(tzinfo=UTC)
|
|
354
|
-
|
|
426
|
+
# Heartbeat-aware ageing: SpawnQueue writes a heartbeat
|
|
427
|
+
# every HEARTBEAT_INTERVAL_SECONDS while it is actively
|
|
428
|
+
# running the spawn, so age from the latest sign of life
|
|
429
|
+
# (received OR heartbeat). Without this a legitimately
|
|
430
|
+
# long coding wake looks identical to a hung spawn.
|
|
431
|
+
last_alive_dt = received_dt
|
|
432
|
+
hb_ts = heartbeats.get(msg_id)
|
|
433
|
+
if hb_ts:
|
|
434
|
+
try:
|
|
435
|
+
hb_dt = datetime.fromisoformat(hb_ts)
|
|
436
|
+
if hb_dt.tzinfo is None:
|
|
437
|
+
hb_dt = hb_dt.replace(tzinfo=UTC)
|
|
438
|
+
if hb_dt > last_alive_dt:
|
|
439
|
+
last_alive_dt = hb_dt
|
|
440
|
+
except (TypeError, ValueError):
|
|
441
|
+
logger.debug(
|
|
442
|
+
"spawn_outbox.find_stuck: unparseable "
|
|
443
|
+
"heartbeat ts on agent=%s msg=%s — ignoring",
|
|
444
|
+
agent_dir.name, msg_id,
|
|
445
|
+
)
|
|
446
|
+
age = (now - last_alive_dt).total_seconds()
|
|
355
447
|
payload = entry.get("payload") or {}
|
|
356
448
|
priority_raw = (
|
|
357
449
|
payload.get("priority") if isinstance(payload, dict) else None
|
|
@@ -375,10 +467,12 @@ class SpawnOutbox:
|
|
|
375
467
|
return out
|
|
376
468
|
|
|
377
469
|
async def compact_agent(self, agent_id: str) -> int:
|
|
378
|
-
"""Manually compact one agent's jsonl.
|
|
470
|
+
"""Manually compact one agent's jsonl.
|
|
379
471
|
|
|
380
|
-
|
|
381
|
-
|
|
472
|
+
Returns the number of still-pending spawns kept (heartbeat lines
|
|
473
|
+
retained alongside them do not count). Public entry point for
|
|
474
|
+
cron-style maintenance; normal flow triggers compaction
|
|
475
|
+
automatically inside :meth:`record_done`.
|
|
382
476
|
"""
|
|
383
477
|
path = self._path_for(agent_id)
|
|
384
478
|
if not path.exists():
|
|
@@ -403,6 +497,7 @@ class SpawnOutbox:
|
|
|
403
497
|
path = self._path_for(agent_id)
|
|
404
498
|
received: dict[str, str] = {} # msg_id → raw line (preserves ts)
|
|
405
499
|
done_ids: set[str] = set()
|
|
500
|
+
heartbeats: dict[str, str] = {} # msg_id → latest heartbeat raw line
|
|
406
501
|
for raw in lines:
|
|
407
502
|
raw = raw.strip()
|
|
408
503
|
if not raw:
|
|
@@ -419,8 +514,25 @@ class SpawnOutbox:
|
|
|
419
514
|
received[msg_id] = raw
|
|
420
515
|
elif event == "done":
|
|
421
516
|
done_ids.add(msg_id)
|
|
422
|
-
|
|
423
|
-
|
|
517
|
+
elif event == "heartbeat":
|
|
518
|
+
# Append-ordered → last write wins.
|
|
519
|
+
heartbeats[msg_id] = raw
|
|
520
|
+
|
|
521
|
+
# Keep each still-pending ``received`` line plus its latest
|
|
522
|
+
# ``heartbeat`` (if any) so find_stuck's heartbeat-aware ageing
|
|
523
|
+
# survives compaction — dropping the heartbeat would snap a
|
|
524
|
+
# long-running spawn back to "aged from received" and the
|
|
525
|
+
# watchdog would false-fire.
|
|
526
|
+
keep: list[str] = []
|
|
527
|
+
pending_count = 0
|
|
528
|
+
for msg, raw in received.items():
|
|
529
|
+
if msg in done_ids:
|
|
530
|
+
continue
|
|
531
|
+
keep.append(raw)
|
|
532
|
+
pending_count += 1
|
|
533
|
+
hb = heartbeats.get(msg)
|
|
534
|
+
if hb is not None:
|
|
535
|
+
keep.append(hb)
|
|
424
536
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
425
537
|
with tmp.open("w", encoding="utf-8") as f:
|
|
426
538
|
for raw in keep:
|
|
@@ -432,12 +544,13 @@ class SpawnOutbox:
|
|
|
432
544
|
len(lines),
|
|
433
545
|
len(keep),
|
|
434
546
|
)
|
|
435
|
-
return
|
|
547
|
+
return pending_count
|
|
436
548
|
|
|
437
549
|
|
|
438
550
|
__all__ = [
|
|
439
551
|
"SpawnOutbox",
|
|
440
552
|
"StuckSpawn",
|
|
441
553
|
"STUCK_THRESHOLDS_SECONDS",
|
|
554
|
+
"HEARTBEAT_INTERVAL_SECONDS",
|
|
442
555
|
"DEFAULT_COMPACT_THRESHOLD",
|
|
443
556
|
]
|
|
@@ -13,12 +13,15 @@ Design
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
|
+
import contextlib
|
|
16
17
|
import logging
|
|
17
18
|
import math
|
|
18
19
|
import os
|
|
19
20
|
from collections.abc import Awaitable, Callable
|
|
20
21
|
from typing import TYPE_CHECKING
|
|
21
22
|
|
|
23
|
+
from cloudagent_daemon.spawn_outbox import HEARTBEAT_INTERVAL_SECONDS
|
|
24
|
+
|
|
22
25
|
if TYPE_CHECKING:
|
|
23
26
|
from cloudagent_daemon.spawn_outbox import SpawnOutbox
|
|
24
27
|
|
|
@@ -168,6 +171,18 @@ class SpawnQueue:
|
|
|
168
171
|
msg_id = str(payload.get("message_id") or "")
|
|
169
172
|
ok = True
|
|
170
173
|
async with self._semaphore:
|
|
174
|
+
# 2026-05-20 — keep a heartbeat ticking for as long as
|
|
175
|
+
# run_one is in flight so the outbox-stuck watchdog can
|
|
176
|
+
# tell a legitimately long spawn (a 30-60+ min coding
|
|
177
|
+
# wake) from a hung one. Without this the watchdog aged
|
|
178
|
+
# the spawn from ``received`` and self-killed the daemon
|
|
179
|
+
# mid-wake. See spawn_outbox.STUCK_THRESHOLDS_SECONDS.
|
|
180
|
+
heartbeat_task: asyncio.Task | None = None
|
|
181
|
+
if self.spawn_outbox is not None and msg_id:
|
|
182
|
+
heartbeat_task = asyncio.create_task(
|
|
183
|
+
self._heartbeat_loop(agent_id, msg_id),
|
|
184
|
+
name=f"spawn-heartbeat-{agent_id}",
|
|
185
|
+
)
|
|
171
186
|
try:
|
|
172
187
|
await self.runner.run_one(agent_id, payload)
|
|
173
188
|
except Exception:
|
|
@@ -176,6 +191,12 @@ class SpawnQueue:
|
|
|
176
191
|
"SpawnQueue: runner.run_one raised for agent=%s", agent_id
|
|
177
192
|
)
|
|
178
193
|
finally:
|
|
194
|
+
if heartbeat_task is not None:
|
|
195
|
+
heartbeat_task.cancel()
|
|
196
|
+
with contextlib.suppress(
|
|
197
|
+
asyncio.CancelledError, Exception
|
|
198
|
+
):
|
|
199
|
+
await heartbeat_task
|
|
179
200
|
queue.task_done()
|
|
180
201
|
# Bug 4 — mark the spawn done in the outbox (regardless
|
|
181
202
|
# of runner outcome) so the boot replay path stops
|
|
@@ -198,3 +219,31 @@ class SpawnQueue:
|
|
|
198
219
|
# Remove self from the task registry so the next enqueue can
|
|
199
220
|
# re-create a fresh drain task.
|
|
200
221
|
self._tasks.pop(agent_id, None)
|
|
222
|
+
|
|
223
|
+
async def _heartbeat_loop(self, agent_id: str, msg_id: str) -> None:
|
|
224
|
+
"""Append a spawn heartbeat every ``HEARTBEAT_INTERVAL_SECONDS``.
|
|
225
|
+
|
|
226
|
+
Started by :meth:`_drain` immediately before ``runner.run_one``
|
|
227
|
+
and cancelled in its ``finally`` block, so a heartbeat is on
|
|
228
|
+
record for the whole lifetime of the spawn. This is what tells
|
|
229
|
+
the outbox-stuck watchdog that a long-running spawn is alive and
|
|
230
|
+
progressing rather than hung — see
|
|
231
|
+
``spawn_outbox.STUCK_THRESHOLDS_SECONDS``.
|
|
232
|
+
|
|
233
|
+
Only runs when an outbox is wired (``self.spawn_outbox`` set).
|
|
234
|
+
A heartbeat write failure is best-effort: it is logged and the
|
|
235
|
+
loop continues, since one missed beat only shortens a single
|
|
236
|
+
gap and never warrants killing the loop.
|
|
237
|
+
"""
|
|
238
|
+
if self.spawn_outbox is None:
|
|
239
|
+
return
|
|
240
|
+
while True:
|
|
241
|
+
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
|
242
|
+
try:
|
|
243
|
+
await self.spawn_outbox.record_heartbeat(agent_id, msg_id)
|
|
244
|
+
except Exception:
|
|
245
|
+
logger.debug(
|
|
246
|
+
"SpawnQueue: spawn heartbeat write failed agent=%s msg=%s",
|
|
247
|
+
agent_id,
|
|
248
|
+
msg_id,
|
|
249
|
+
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|