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,443 @@
|
|
|
1
|
+
"""Durable spawn outbox — append-only journal for accepted deliver frames.
|
|
2
|
+
|
|
3
|
+
Bug 4 in ``docs/debug/2026-05-12-task-multi-agent-debug.md``:
|
|
4
|
+
``frame_dispatch._on_deliver`` ack'd inbound deliver frames before the
|
|
5
|
+
claude spawn actually ran. If the daemon crashed (SIGKILL, OOM, panic)
|
|
6
|
+
or :class:`SpawnQueue` lost the in-memory queue entry, the server saw
|
|
7
|
+
the ack and never redelivered → the spawn was silently lost (the
|
|
8
|
+
chairman's "msg_209 / msg_210 sat unread for 24h" prod symptom).
|
|
9
|
+
|
|
10
|
+
This module makes spawn-acceptance durable:
|
|
11
|
+
|
|
12
|
+
1. :meth:`SpawnOutbox.record_received` is invoked from the dispatcher
|
|
13
|
+
**before** the server-side ack. The payload + msg_id + wake_id are
|
|
14
|
+
appended to
|
|
15
|
+
``<workspace>/<agent_id>/.meta/pending-spawn.jsonl``.
|
|
16
|
+
2. :meth:`SpawnOutbox.record_done` is invoked by :class:`SpawnQueue`
|
|
17
|
+
after the runner returns (success or error). ``ok=False`` is the
|
|
18
|
+
"runner raised" path — we still mark done so the wake isn't
|
|
19
|
+
replayed forever on each boot.
|
|
20
|
+
3. :meth:`SpawnOutbox.find_pending` is called from the daemon main
|
|
21
|
+
loop on startup and yields the original payloads of any received
|
|
22
|
+
entry without a matching done entry. The main loop re-enqueues
|
|
23
|
+
those onto :class:`SpawnQueue` so the spawn finally runs.
|
|
24
|
+
4. :meth:`SpawnOutbox.compact_agent` rewrites the jsonl with only the
|
|
25
|
+
unresolved received entries. Triggered automatically after each
|
|
26
|
+
done write once the file exceeds ``compact_threshold`` lines.
|
|
27
|
+
|
|
28
|
+
File format — newline-delimited JSON, one entry per line. Two shapes:
|
|
29
|
+
|
|
30
|
+
``{"event": "received", "msg_id": str, "wake_id": str | null,
|
|
31
|
+
"ts": iso, "payload": {...}}``
|
|
32
|
+
``{"event": "done", "msg_id": str, "ts": iso, "ok": bool}``
|
|
33
|
+
|
|
34
|
+
Concurrency: a per-agent :class:`asyncio.Lock` serialises writes /
|
|
35
|
+
compaction. ``find_pending`` runs on boot before any writer starts so
|
|
36
|
+
needs no lock. The boot path is single-threaded by design.
|
|
37
|
+
|
|
38
|
+
Durability tradeoff: writes go through ``open("a") + flush()`` — no
|
|
39
|
+
``fsync``. A full OS crash / power cut can still lose the most recent
|
|
40
|
+
line. For the prod use case (daemon crash / SIGKILL only) the page
|
|
41
|
+
cache survives. ``fsync`` per write would halve daemon throughput at
|
|
42
|
+
load; revisit only if real-world data loss surfaces.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from datetime import UTC, datetime
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
from cloudagent_daemon.workspace import Workspace
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
# Default compaction trigger — when a single agent's jsonl exceeds this
|
|
59
|
+
# many lines, rewrite as only the pending entries. Picked so steady-state
|
|
60
|
+
# busy daemon doesn't grow unbounded. Tunable per-instance.
|
|
61
|
+
DEFAULT_COMPACT_THRESHOLD = 200
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Wave C #2 — outbox-stuck self-kill thresholds
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
#
|
|
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.
|
|
74
|
+
#
|
|
75
|
+
# Keys are the lowercase stringified priority values; missing /
|
|
76
|
+
# unrecognised priorities use the ``None`` bucket. main_loop's
|
|
77
|
+
# outbox-watchdog uses these to decide when to log + SystemExit(1) so
|
|
78
|
+
# launchd respawns and the boot-replay path re-enqueues the stuck spawn.
|
|
79
|
+
STUCK_THRESHOLDS_SECONDS: dict[str | None, float] = {
|
|
80
|
+
"p0": 5 * 60.0,
|
|
81
|
+
"p1": 15 * 60.0,
|
|
82
|
+
"p2": 60 * 60.0,
|
|
83
|
+
None: 30 * 60.0,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class StuckSpawn:
|
|
89
|
+
"""One ``received``-without-``done`` entry past its priority threshold.
|
|
90
|
+
|
|
91
|
+
Returned by :meth:`SpawnOutbox.find_stuck`. Surfaced into the
|
|
92
|
+
daemon's structured-error log and the ``/metrics`` payload before
|
|
93
|
+
the outbox-watchdog escalates to ``SystemExit(1)``.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
agent_id: str
|
|
97
|
+
msg_id: str
|
|
98
|
+
received_at: str
|
|
99
|
+
age_seconds: float
|
|
100
|
+
priority: str | None
|
|
101
|
+
threshold_seconds: float
|
|
102
|
+
payload: dict
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SpawnOutbox:
|
|
106
|
+
"""Per-host durable journal of inbound deliver frames.
|
|
107
|
+
|
|
108
|
+
One instance lives on the daemon. The jsonl files are sharded
|
|
109
|
+
per-agent so an active agent's append churn doesn't bottleneck
|
|
110
|
+
on another agent's compaction.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
workspace: Workspace,
|
|
116
|
+
*,
|
|
117
|
+
compact_threshold: int = DEFAULT_COMPACT_THRESHOLD,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.workspace = workspace
|
|
120
|
+
self.compact_threshold = compact_threshold
|
|
121
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Internals
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _path_for(self, agent_id: str) -> Path:
|
|
128
|
+
return (
|
|
129
|
+
self.workspace.for_agent(agent_id).path
|
|
130
|
+
/ ".meta"
|
|
131
|
+
/ "pending-spawn.jsonl"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _lock_for(self, agent_id: str) -> asyncio.Lock:
|
|
135
|
+
lock = self._locks.get(agent_id)
|
|
136
|
+
if lock is None:
|
|
137
|
+
lock = asyncio.Lock()
|
|
138
|
+
self._locks[agent_id] = lock
|
|
139
|
+
return lock
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _now_iso() -> str:
|
|
143
|
+
return datetime.now(UTC).isoformat()
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Public API
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async def record_received(self, payload: dict) -> None:
|
|
150
|
+
"""Persist a freshly accepted deliver payload.
|
|
151
|
+
|
|
152
|
+
Must run BEFORE the server-side ack, otherwise the durability
|
|
153
|
+
guarantee is lost. Errors propagate so the caller can refuse
|
|
154
|
+
to ack (server will redeliver next reconnect).
|
|
155
|
+
"""
|
|
156
|
+
agent_id = str(payload.get("agent_id") or "")
|
|
157
|
+
msg_id = str(payload.get("message_id") or "")
|
|
158
|
+
if not agent_id or not msg_id:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"spawn_outbox: payload missing agent_id / message_id"
|
|
161
|
+
)
|
|
162
|
+
wake_id = payload.get("wake_id")
|
|
163
|
+
path = self._path_for(agent_id)
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
line = json.dumps(
|
|
166
|
+
{
|
|
167
|
+
"event": "received",
|
|
168
|
+
"msg_id": msg_id,
|
|
169
|
+
"wake_id": wake_id,
|
|
170
|
+
"ts": self._now_iso(),
|
|
171
|
+
"payload": payload,
|
|
172
|
+
},
|
|
173
|
+
ensure_ascii=False,
|
|
174
|
+
)
|
|
175
|
+
async with self._lock_for(agent_id):
|
|
176
|
+
with path.open("a", encoding="utf-8") as f:
|
|
177
|
+
f.write(line + "\n")
|
|
178
|
+
f.flush()
|
|
179
|
+
logger.debug(
|
|
180
|
+
"spawn_outbox: recorded received agent=%s msg=%s",
|
|
181
|
+
agent_id,
|
|
182
|
+
msg_id,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def record_done(
|
|
186
|
+
self, agent_id: str, msg_id: str, *, ok: bool = True
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Mark a spawn as completed (success or error path).
|
|
189
|
+
|
|
190
|
+
Called after ``runner.run_one`` returns. ``ok=False`` is the
|
|
191
|
+
runner-raised-exception path; we still mark done so the wake
|
|
192
|
+
isn't replayed every boot. Idempotent: repeated calls just
|
|
193
|
+
append another done line, which the compact path collapses.
|
|
194
|
+
"""
|
|
195
|
+
agent_id = str(agent_id or "")
|
|
196
|
+
msg_id = str(msg_id or "")
|
|
197
|
+
if not agent_id or not msg_id:
|
|
198
|
+
return
|
|
199
|
+
path = self._path_for(agent_id)
|
|
200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
line = json.dumps(
|
|
202
|
+
{
|
|
203
|
+
"event": "done",
|
|
204
|
+
"msg_id": msg_id,
|
|
205
|
+
"ts": self._now_iso(),
|
|
206
|
+
"ok": ok,
|
|
207
|
+
},
|
|
208
|
+
ensure_ascii=False,
|
|
209
|
+
)
|
|
210
|
+
async with self._lock_for(agent_id):
|
|
211
|
+
with path.open("a", encoding="utf-8") as f:
|
|
212
|
+
f.write(line + "\n")
|
|
213
|
+
f.flush()
|
|
214
|
+
# Lazy compaction inside the same critical section so a
|
|
215
|
+
# concurrent writer doesn't race with the rewrite.
|
|
216
|
+
try:
|
|
217
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
218
|
+
except OSError:
|
|
219
|
+
lines = []
|
|
220
|
+
if len(lines) > self.compact_threshold:
|
|
221
|
+
self._compact_locked(agent_id, lines)
|
|
222
|
+
logger.debug(
|
|
223
|
+
"spawn_outbox: recorded done agent=%s msg=%s ok=%s",
|
|
224
|
+
agent_id,
|
|
225
|
+
msg_id,
|
|
226
|
+
ok,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def find_pending(self) -> list[dict[str, Any]]:
|
|
230
|
+
"""Scan every agent's jsonl for received-without-done entries.
|
|
231
|
+
|
|
232
|
+
Returns the original payload dicts (same shape as the deliver
|
|
233
|
+
frame's ``payload`` key) ready to feed back into
|
|
234
|
+
:meth:`SpawnQueue.enqueue`. Order is stable: per-agent
|
|
235
|
+
directory sort + per-file insertion order (oldest first).
|
|
236
|
+
"""
|
|
237
|
+
out: list[dict[str, Any]] = []
|
|
238
|
+
if not self.workspace.root.exists():
|
|
239
|
+
return out
|
|
240
|
+
for agent_dir in sorted(self.workspace.root.iterdir()):
|
|
241
|
+
if not agent_dir.is_dir():
|
|
242
|
+
continue
|
|
243
|
+
jsonl = agent_dir / ".meta" / "pending-spawn.jsonl"
|
|
244
|
+
if not jsonl.exists():
|
|
245
|
+
continue
|
|
246
|
+
received: dict[str, dict[str, Any]] = {}
|
|
247
|
+
done_ids: set[str] = set()
|
|
248
|
+
try:
|
|
249
|
+
lines = jsonl.read_text(encoding="utf-8").splitlines()
|
|
250
|
+
except OSError:
|
|
251
|
+
continue
|
|
252
|
+
for raw in lines:
|
|
253
|
+
raw = raw.strip()
|
|
254
|
+
if not raw:
|
|
255
|
+
continue
|
|
256
|
+
try:
|
|
257
|
+
entry = json.loads(raw)
|
|
258
|
+
except (TypeError, ValueError):
|
|
259
|
+
# Skip corrupt / partial lines (mid-write crash).
|
|
260
|
+
continue
|
|
261
|
+
event = entry.get("event")
|
|
262
|
+
msg_id = str(entry.get("msg_id") or "")
|
|
263
|
+
if not msg_id:
|
|
264
|
+
continue
|
|
265
|
+
if event == "received":
|
|
266
|
+
received[msg_id] = entry
|
|
267
|
+
elif event == "done":
|
|
268
|
+
done_ids.add(msg_id)
|
|
269
|
+
for msg_id, entry in received.items():
|
|
270
|
+
if msg_id in done_ids:
|
|
271
|
+
continue
|
|
272
|
+
payload = entry.get("payload")
|
|
273
|
+
if isinstance(payload, dict):
|
|
274
|
+
out.append(payload)
|
|
275
|
+
if out:
|
|
276
|
+
logger.info(
|
|
277
|
+
"spawn_outbox: boot-replay found %d pending spawn(s)",
|
|
278
|
+
len(out),
|
|
279
|
+
)
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
async def find_stuck(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
now: datetime | None = None,
|
|
286
|
+
thresholds: dict[str | None, float] | None = None,
|
|
287
|
+
) -> list[StuckSpawn]:
|
|
288
|
+
"""Return all ``received``-without-``done`` entries past their
|
|
289
|
+
per-priority age threshold (Wave C #2).
|
|
290
|
+
|
|
291
|
+
``thresholds`` falls back to :data:`STUCK_THRESHOLDS_SECONDS` —
|
|
292
|
+
callers should generally pass ``None`` and let the module-level
|
|
293
|
+
contract decide. ``now`` defaults to ``datetime.now(UTC)``; tests
|
|
294
|
+
inject a fixed clock.
|
|
295
|
+
|
|
296
|
+
Resilient to corrupt JSONL lines (skipped) and unparseable
|
|
297
|
+
timestamps (treated as not-stuck, since we can't meaningfully
|
|
298
|
+
compute age — emits a debug log so an operator can audit).
|
|
299
|
+
Priority lookup is case-insensitive and falls through to the
|
|
300
|
+
``None`` (default) bucket for any unrecognised value.
|
|
301
|
+
"""
|
|
302
|
+
out: list[StuckSpawn] = []
|
|
303
|
+
if not self.workspace.root.exists():
|
|
304
|
+
return out
|
|
305
|
+
thresholds = thresholds or STUCK_THRESHOLDS_SECONDS
|
|
306
|
+
default_threshold = thresholds.get(None, 30 * 60.0)
|
|
307
|
+
now = now or datetime.now(UTC)
|
|
308
|
+
|
|
309
|
+
for agent_dir in sorted(self.workspace.root.iterdir()):
|
|
310
|
+
if not agent_dir.is_dir():
|
|
311
|
+
continue
|
|
312
|
+
jsonl = agent_dir / ".meta" / "pending-spawn.jsonl"
|
|
313
|
+
if not jsonl.exists():
|
|
314
|
+
continue
|
|
315
|
+
received: dict[str, dict[str, Any]] = {}
|
|
316
|
+
done_ids: set[str] = set()
|
|
317
|
+
try:
|
|
318
|
+
lines = jsonl.read_text(encoding="utf-8").splitlines()
|
|
319
|
+
except OSError:
|
|
320
|
+
continue
|
|
321
|
+
for raw in lines:
|
|
322
|
+
raw = raw.strip()
|
|
323
|
+
if not raw:
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
entry = json.loads(raw)
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
continue
|
|
329
|
+
event = entry.get("event")
|
|
330
|
+
msg_id = str(entry.get("msg_id") or "")
|
|
331
|
+
if not msg_id:
|
|
332
|
+
continue
|
|
333
|
+
if event == "received":
|
|
334
|
+
received[msg_id] = entry
|
|
335
|
+
elif event == "done":
|
|
336
|
+
done_ids.add(msg_id)
|
|
337
|
+
for msg_id, entry in received.items():
|
|
338
|
+
if msg_id in done_ids:
|
|
339
|
+
continue
|
|
340
|
+
received_at = str(entry.get("ts") or "")
|
|
341
|
+
try:
|
|
342
|
+
received_dt = datetime.fromisoformat(received_at)
|
|
343
|
+
except (TypeError, ValueError):
|
|
344
|
+
logger.debug(
|
|
345
|
+
"spawn_outbox.find_stuck: unparseable ts on "
|
|
346
|
+
"agent=%s msg=%s — skipping", agent_dir.name, msg_id,
|
|
347
|
+
)
|
|
348
|
+
continue
|
|
349
|
+
# Naive timestamps default to UTC for backwards-compat;
|
|
350
|
+
# all writes since the outbox shipped have used tz-aware
|
|
351
|
+
# ISO strings but tests may construct fixtures differently.
|
|
352
|
+
if received_dt.tzinfo is None:
|
|
353
|
+
received_dt = received_dt.replace(tzinfo=UTC)
|
|
354
|
+
age = (now - received_dt).total_seconds()
|
|
355
|
+
payload = entry.get("payload") or {}
|
|
356
|
+
priority_raw = (
|
|
357
|
+
payload.get("priority") if isinstance(payload, dict) else None
|
|
358
|
+
)
|
|
359
|
+
priority_key: str | None = (
|
|
360
|
+
str(priority_raw).lower() if priority_raw else None
|
|
361
|
+
)
|
|
362
|
+
threshold = thresholds.get(priority_key, default_threshold)
|
|
363
|
+
if age > threshold:
|
|
364
|
+
out.append(
|
|
365
|
+
StuckSpawn(
|
|
366
|
+
agent_id=agent_dir.name,
|
|
367
|
+
msg_id=msg_id,
|
|
368
|
+
received_at=received_at,
|
|
369
|
+
age_seconds=age,
|
|
370
|
+
priority=priority_key,
|
|
371
|
+
threshold_seconds=threshold,
|
|
372
|
+
payload=payload if isinstance(payload, dict) else {},
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
async def compact_agent(self, agent_id: str) -> int:
|
|
378
|
+
"""Manually compact one agent's jsonl. Returns kept line count.
|
|
379
|
+
|
|
380
|
+
Public entry point for cron-style maintenance; normal flow
|
|
381
|
+
triggers compaction automatically inside :meth:`record_done`.
|
|
382
|
+
"""
|
|
383
|
+
path = self._path_for(agent_id)
|
|
384
|
+
if not path.exists():
|
|
385
|
+
return 0
|
|
386
|
+
async with self._lock_for(agent_id):
|
|
387
|
+
try:
|
|
388
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
389
|
+
except OSError:
|
|
390
|
+
return 0
|
|
391
|
+
return self._compact_locked(agent_id, lines)
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
# Locked helpers
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def _compact_locked(self, agent_id: str, lines: list[str]) -> int:
|
|
398
|
+
"""Rewrite the jsonl with only received-without-done entries.
|
|
399
|
+
|
|
400
|
+
Caller must hold ``_lock_for(agent_id)``. Returns the number
|
|
401
|
+
of pending entries kept after rewrite.
|
|
402
|
+
"""
|
|
403
|
+
path = self._path_for(agent_id)
|
|
404
|
+
received: dict[str, str] = {} # msg_id → raw line (preserves ts)
|
|
405
|
+
done_ids: set[str] = set()
|
|
406
|
+
for raw in lines:
|
|
407
|
+
raw = raw.strip()
|
|
408
|
+
if not raw:
|
|
409
|
+
continue
|
|
410
|
+
try:
|
|
411
|
+
entry = json.loads(raw)
|
|
412
|
+
except (TypeError, ValueError):
|
|
413
|
+
continue
|
|
414
|
+
event = entry.get("event")
|
|
415
|
+
msg_id = str(entry.get("msg_id") or "")
|
|
416
|
+
if not msg_id:
|
|
417
|
+
continue
|
|
418
|
+
if event == "received":
|
|
419
|
+
received[msg_id] = raw
|
|
420
|
+
elif event == "done":
|
|
421
|
+
done_ids.add(msg_id)
|
|
422
|
+
|
|
423
|
+
keep = [raw for msg, raw in received.items() if msg not in done_ids]
|
|
424
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
425
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
426
|
+
for raw in keep:
|
|
427
|
+
f.write(raw + "\n")
|
|
428
|
+
tmp.replace(path)
|
|
429
|
+
logger.info(
|
|
430
|
+
"spawn_outbox: compacted agent=%s lines=%d→%d",
|
|
431
|
+
agent_id,
|
|
432
|
+
len(lines),
|
|
433
|
+
len(keep),
|
|
434
|
+
)
|
|
435
|
+
return len(keep)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
__all__ = [
|
|
439
|
+
"SpawnOutbox",
|
|
440
|
+
"StuckSpawn",
|
|
441
|
+
"STUCK_THRESHOLDS_SECONDS",
|
|
442
|
+
"DEFAULT_COMPACT_THRESHOLD",
|
|
443
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""SpawnQueue: per-agent FIFO + global concurrency cap.
|
|
2
|
+
|
|
3
|
+
Implements spec §5 L4 (per-agent cap) and L5 (global concurrency cap).
|
|
4
|
+
|
|
5
|
+
Design
|
|
6
|
+
------
|
|
7
|
+
* One ``asyncio.Queue`` per agent_id, bounded by ``max_per_agent``.
|
|
8
|
+
* One drain task per agent — created on the first enqueue; exits when the
|
|
9
|
+
agent queue empties; re-created if a new message arrives later.
|
|
10
|
+
* A single ``asyncio.Semaphore(max_concurrent)`` gates all concurrent
|
|
11
|
+
``runner.run_one`` calls across all agents.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from cloudagent_daemon.spawn_outbox import SpawnOutbox
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_max_concurrent() -> int:
|
|
29
|
+
return min(8, math.ceil((os.cpu_count() or 4) / 2))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SpawnQueue:
|
|
33
|
+
"""Per-agent FIFO queue with a global concurrency cap.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
runner:
|
|
38
|
+
Any object with ``async def run_one(agent_id: str, payload: dict)``.
|
|
39
|
+
The real implementation is ``ClaudeRunner`` (Task 9); tests use a fake.
|
|
40
|
+
max_per_agent:
|
|
41
|
+
Spec §5 L4 — maximum queued items per agent (default 100).
|
|
42
|
+
max_concurrent:
|
|
43
|
+
Spec §5 L5 — global concurrency cap. ``None`` defaults to
|
|
44
|
+
``min(8, ceil(cpu_count / 2))``.
|
|
45
|
+
queue_full_callback:
|
|
46
|
+
Called with ``(agent_id, message_id)`` when a per-agent queue is full
|
|
47
|
+
so the daemon can ack the deliver as ``queue_full``.
|
|
48
|
+
spawn_outbox:
|
|
49
|
+
Optional; Bug 4 durable journal. When provided, every drained
|
|
50
|
+
payload triggers :meth:`SpawnOutbox.record_done` after the
|
|
51
|
+
runner returns (success or error) so the boot-replay path can
|
|
52
|
+
tell the spawn finished. See ``spawn_outbox.py``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
runner,
|
|
58
|
+
*,
|
|
59
|
+
max_per_agent: int = 100,
|
|
60
|
+
max_concurrent: int | None = None,
|
|
61
|
+
queue_full_callback: Callable[[str, str], Awaitable[None]] | None = None,
|
|
62
|
+
spawn_outbox: "SpawnOutbox | None" = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.runner = runner
|
|
65
|
+
self.max_per_agent = max_per_agent
|
|
66
|
+
self.max_concurrent: int = max_concurrent or _default_max_concurrent()
|
|
67
|
+
self.queue_full_callback = queue_full_callback
|
|
68
|
+
self.spawn_outbox = spawn_outbox
|
|
69
|
+
|
|
70
|
+
self._semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
71
|
+
# agent_id → asyncio.Queue
|
|
72
|
+
self._queues: dict[str, asyncio.Queue] = {}
|
|
73
|
+
# agent_id → running drain Task
|
|
74
|
+
self._tasks: dict[str, asyncio.Task] = {}
|
|
75
|
+
self._shutdown = False
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Public API
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async def enqueue(self, deliver_payload: dict) -> bool:
|
|
82
|
+
"""Queue a deliver payload for the agent.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
True
|
|
87
|
+
Item was accepted into the queue.
|
|
88
|
+
False
|
|
89
|
+
Per-agent queue is full; item was dropped (callback fired if set).
|
|
90
|
+
"""
|
|
91
|
+
if self._shutdown:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
agent_id: str = deliver_payload["agent_id"]
|
|
95
|
+
message_id: str = deliver_payload["message_id"]
|
|
96
|
+
|
|
97
|
+
# Lazily create a per-agent queue.
|
|
98
|
+
if agent_id not in self._queues:
|
|
99
|
+
self._queues[agent_id] = asyncio.Queue(maxsize=self.max_per_agent)
|
|
100
|
+
|
|
101
|
+
queue = self._queues[agent_id]
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
queue.put_nowait(deliver_payload)
|
|
105
|
+
except asyncio.QueueFull:
|
|
106
|
+
logger.warning(
|
|
107
|
+
"SpawnQueue: agent=%s queue full (cap=%d), dropping message=%s",
|
|
108
|
+
agent_id,
|
|
109
|
+
self.max_per_agent,
|
|
110
|
+
message_id,
|
|
111
|
+
)
|
|
112
|
+
if self.queue_full_callback is not None:
|
|
113
|
+
await self.queue_full_callback(agent_id, message_id)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# 1i Bug C — Phase 1 demo visibility.
|
|
117
|
+
logger.info(
|
|
118
|
+
"spawn_queue: enqueued agent=%s msg=%s queue_depth=%d",
|
|
119
|
+
agent_id,
|
|
120
|
+
message_id,
|
|
121
|
+
queue.qsize(),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Ensure a drain task is running for this agent.
|
|
125
|
+
existing = self._tasks.get(agent_id)
|
|
126
|
+
if existing is None or existing.done():
|
|
127
|
+
task = asyncio.create_task(
|
|
128
|
+
self._drain(agent_id), name=f"drain-{agent_id}"
|
|
129
|
+
)
|
|
130
|
+
self._tasks[agent_id] = task
|
|
131
|
+
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
async def shutdown(self) -> None:
|
|
135
|
+
"""Signal shutdown and wait for all in-flight drain tasks to finish."""
|
|
136
|
+
self._shutdown = True
|
|
137
|
+
tasks = list(self._tasks.values())
|
|
138
|
+
if tasks:
|
|
139
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
140
|
+
|
|
141
|
+
async def cancel_all(self) -> None:
|
|
142
|
+
"""Cancel all drain tasks immediately (used on SIGTERM for fast exit).
|
|
143
|
+
|
|
144
|
+
Unlike ``shutdown()``, this does not wait for in-flight ``run_one``
|
|
145
|
+
calls to complete — it cancels them. Use when a fast, clean exit is
|
|
146
|
+
required (e.g., SIGTERM handling in the daemon start command).
|
|
147
|
+
"""
|
|
148
|
+
self._shutdown = True
|
|
149
|
+
tasks = list(self._tasks.values())
|
|
150
|
+
for t in tasks:
|
|
151
|
+
t.cancel()
|
|
152
|
+
if tasks:
|
|
153
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Internal
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async def _drain(self, agent_id: str) -> None:
|
|
160
|
+
"""Drain the per-agent queue until it is empty."""
|
|
161
|
+
queue = self._queues[agent_id]
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
payload = queue.get_nowait()
|
|
165
|
+
except asyncio.QueueEmpty:
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
msg_id = str(payload.get("message_id") or "")
|
|
169
|
+
ok = True
|
|
170
|
+
async with self._semaphore:
|
|
171
|
+
try:
|
|
172
|
+
await self.runner.run_one(agent_id, payload)
|
|
173
|
+
except Exception:
|
|
174
|
+
ok = False
|
|
175
|
+
logger.exception(
|
|
176
|
+
"SpawnQueue: runner.run_one raised for agent=%s", agent_id
|
|
177
|
+
)
|
|
178
|
+
finally:
|
|
179
|
+
queue.task_done()
|
|
180
|
+
# Bug 4 — mark the spawn done in the outbox (regardless
|
|
181
|
+
# of runner outcome) so the boot replay path stops
|
|
182
|
+
# considering it pending. Failure to write the done
|
|
183
|
+
# marker is logged but non-fatal — next compact + next
|
|
184
|
+
# boot will re-enqueue, runner has its own dedupe.
|
|
185
|
+
if self.spawn_outbox is not None and msg_id:
|
|
186
|
+
try:
|
|
187
|
+
await self.spawn_outbox.record_done(
|
|
188
|
+
agent_id, msg_id, ok=ok
|
|
189
|
+
)
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception(
|
|
192
|
+
"SpawnQueue: spawn_outbox.record_done failed "
|
|
193
|
+
"agent=%s msg=%s",
|
|
194
|
+
agent_id,
|
|
195
|
+
msg_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Remove self from the task registry so the next enqueue can
|
|
199
|
+
# re-create a fresh drain task.
|
|
200
|
+
self._tasks.pop(agent_id, None)
|