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.
Files changed (33) hide show
  1. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/PKG-INFO +1 -1
  2. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/pyproject.toml +1 -1
  3. cloudagent_daemon-0.2.6/src/cloudagent_daemon/__init__.py +1 -0
  4. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/main_loop.py +8 -8
  5. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/service.py +60 -19
  6. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/spawn_outbox.py +128 -15
  7. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/spawn_queue.py +49 -0
  8. cloudagent_daemon-0.2.4/src/cloudagent_daemon/__init__.py +0 -1
  9. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/.gitignore +0 -0
  10. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/LICENSE +0 -0
  11. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/README.md +0 -0
  12. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/__main__.py +0 -0
  13. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/claude_runner.py +0 -0
  14. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/cli.py +0 -0
  15. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/config.py +0 -0
  16. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/doctor.py +0 -0
  17. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  18. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/http_outbound.py +0 -0
  19. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  20. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/login.py +0 -0
  21. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  22. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/metrics.py +0 -0
  23. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/metrics_server.py +0 -0
  24. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/outbound_queue.py +0 -0
  25. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/paths.py +0 -0
  26. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/protocol.py +0 -0
  27. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/runtime_detect.py +0 -0
  28. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/sse_client.py +0 -0
  29. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/transport.py +0 -0
  30. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/unix_socket.py +0 -0
  31. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/workspace.py +0 -0
  32. {cloudagent_daemon-0.2.4 → cloudagent_daemon-0.2.6}/src/cloudagent_daemon/ws_client.py +0 -0
  33. {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.4
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.4"
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``-without-``done`` entries
55
- # older than their per-priority threshold (see
56
- # :data:`spawn_outbox.STUCK_THRESHOLDS_SECONDS`). The first stuck spawn
57
- # escalates to ``SystemExit(1)`` so launchd respawns the daemon and the
58
- # boot-replay path re-enqueues. 60s scan period is tight enough that a
59
- # pathological 30-min default threshold gets caught within ~1 min of
60
- # crossing the line, slack enough that the scan cost (~1 ms per agent
61
- # per pass) is negligible.
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
- # Reinstall-safe (2026-05-19). A plain ``bootstrap`` is a no-op when
204
- # the service is already loaded — launchctl keeps the OLD in-memory
205
- # plist and never re-reads the file we just wrote. Without a prior
206
- # ``bootout`` a re-run of ``install-service`` silently keeps the
207
- # stale definition (e.g. a changed CLOUDAGENT_DAEMON_PERMISSION_MODE
208
- # never takes effect). So: bootout first (unload any loaded
209
- # instance), then bootstrap genuinely loads the fresh plist.
210
- # ``bootout`` exits non-zero when nothing is loaded (first install)
211
- # — ``check=False`` swallows that, making install == reinstall.
212
- subprocess.run(
213
- ["launchctl", "bootout", f"gui/{uid}/{label}"],
214
- check=False,
215
- )
216
- subprocess.run(
217
- ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
218
- check=False,
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", f"gui/{uid}/{label}"],
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. Two shapes:
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
- # Maps deliver-frame ``priority`` to "received-without-done is stuck"
69
- # seconds. Anchored to contract D4 (``docs/debug/2026-05-12-comm-contract-v1.md``
70
- # §D4): P0=5min, P1=15min, P2=60min. The ``None`` (default) bucket is
71
- # 30 min per the Wave C brief — half-way between P1 (15 min) and P2
72
- # (60 min) — to bound stuck windows for backends that don't yet stamp
73
- # the priority field.
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: 30 * 60.0,
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
- age = (now - received_dt).total_seconds()
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. Returns kept line count.
470
+ """Manually compact one agent's jsonl.
379
471
 
380
- Public entry point for cron-style maintenance; normal flow
381
- triggers compaction automatically inside :meth:`record_done`.
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
- keep = [raw for msg, raw in received.items() if msg not in done_ids]
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 len(keep)
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"