cloudagent-daemon 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)