alter-runtime 0.3.0__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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,461 @@
1
+ """AgentFrameSubscriber - projects ``agent_frame`` deliveries to a local JSONL cache.
2
+
3
+ D-AGENT-CHANNEL-1 Phase 2 Wave 6 (§8 of *proposed-D-AGENT-CHANNEL-1.md*).
4
+
5
+ The subscriber is a long-lived :class:`alter_runtime.daemon.Component` that:
6
+
7
+ 1. Subscribes to the in-process :class:`EventBus` on the ``identity.event``
8
+ topic published by :class:`DoSseSubscriber`.
9
+ 2. Filters frames whose ``content_type`` equals ``"x-alter-agent"`` (the
10
+ agent-channel discriminator per D-AGENT-CHANNEL-1 §4.A).
11
+ 3. Deduplicates on ``frame_id`` — a bounded LRU window prevents DO replay
12
+ bursts from writing the same frame twice.
13
+ 4. Atomically appends one compact JSON line per frame to
14
+ ``$XDG_CACHE_HOME/alter/agent-frames.jsonl`` (mode ``0o600``, parent dir
15
+ ``0o700``), rotating to ``agent-frames.jsonl.1`` once the file exceeds
16
+ 10 MiB.
17
+ 5. Publishes to the in-process bus topic ``alter.agent.frame.{kind}`` so hook
18
+ subscribers (CC hooks, adapters) can react to specific frame kinds without
19
+ polling the JSONL cache.
20
+
21
+ The projection path is a pure subscriber — it never sends an acknowledgement
22
+ back to the DO. The DO replays from ``Last-Event-ID`` on reconnect, so write
23
+ failures simply mean the frame is re-offered on the next connection.
24
+
25
+ The component is designed to swallow and log all errors so that a single
26
+ malformed frame, full disk, or transient misbehaviour cannot crash the daemon
27
+ supervisor. The supervisor will still restart the component with exponential
28
+ backoff if :meth:`run` raises, but :meth:`handle_event` only ever logs and
29
+ returns.
30
+
31
+ Scope note (Phase 2 Wave 6):
32
+ - D-Bus signal (``dev.alter.agent.frame``) — Phase 3, out of scope.
33
+ - Multi-org federation proxy (§4.E) — Phase 3, D-STATIC-BINARY-1 Rust binary.
34
+ - Subscription filter enforcement (DO-side) — Wave 4, also out of scope here.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import contextlib
41
+ import errno
42
+ import fcntl
43
+ import json
44
+ import logging
45
+ import os
46
+ from collections import OrderedDict
47
+ from dataclasses import asdict, dataclass, field
48
+ from datetime import datetime, timezone
49
+ from pathlib import Path
50
+ from typing import TYPE_CHECKING, Any
51
+
52
+ from alter_runtime.config import cache_dir
53
+ from alter_runtime.daemon import Component
54
+
55
+ if TYPE_CHECKING:
56
+ from alter_runtime.subscribers.bus import EventBus
57
+
58
+ __all__ = ["AgentFrameSubscriber", "DEFAULT_ROTATION_THRESHOLD_BYTES", "InstrumentRecord"]
59
+
60
+ logger = logging.getLogger("alter_runtime.subscribers.agent_frames")
61
+
62
+ #: SSE ``content_type`` value that discriminates agent channel frames from
63
+ #: human Messenger frames (D-AGENT-CHANNEL-1 §4.A / §9).
64
+ AGENT_CONTENT_TYPE: str = "x-alter-agent"
65
+
66
+ #: Filename for the agent-frames JSONL cache (within ``cache_dir()``).
67
+ FRAMES_FILENAME: str = "agent-frames.jsonl"
68
+
69
+ #: Filename for the rotated tail (single generation).
70
+ FRAMES_ROTATED_FILENAME: str = "agent-frames.jsonl.1"
71
+
72
+ #: Rotate the JSONL file once it exceeds this many bytes (10 MiB, matching the
73
+ #: inbox.jsonl rotation threshold from InboxWriter).
74
+ DEFAULT_ROTATION_THRESHOLD_BYTES: int = 10 * 1024 * 1024
75
+
76
+ #: Maximum number of ``frame_id`` values retained for in-memory dedup. Sized
77
+ #: to absorb a DO replay burst (the same ceiling as do_sse.py FRAME_DEDUP_WINDOW)
78
+ #: while staying cheap in-memory.
79
+ FRAME_DEDUP_WINDOW: int = 256
80
+
81
+ #: Hard cap on the per-frame ``payload`` serialised size written to the cache.
82
+ #: Prevents a pathological large payload from bloating the cache file past the
83
+ #: rotation threshold in a single write.
84
+ MAX_PAYLOAD_BYTES: int = 64 * 1024
85
+
86
+ #: Bus topic pattern for per-kind agent frame events. Callers subscribe to
87
+ #: e.g. ``alter.agent.frame.agent_advisory``. The subscriber publishes the
88
+ #: full deserialised frame dict as the payload.
89
+ TOPIC_AGENT_FRAME_PREFIX: str = "alter.agent.frame."
90
+
91
+
92
+ @dataclass
93
+ class InstrumentRecord:
94
+ """A single entry in the in-memory instrument roster.
95
+
96
+ Populated from observed ``agent_frame`` sender fields. Represents one
97
+ agentic surface (CC session, Codex window, Cursor IDE, alter-cli verb,
98
+ alter-runtime daemon) that has emitted at least one frame visible to
99
+ this daemon instance.
100
+
101
+ Fields mirror the ``alter_agent_roster`` MCP verb shape
102
+ (D-AGENT-CHANNEL-1 §5) so the unix-socket ``agent/roster`` route can
103
+ return the same shape without a DO round-trip.
104
+ """
105
+
106
+ handle: str
107
+ instrument: str
108
+ tool: str
109
+ session_id: str | None = None
110
+ started_at: str | None = None
111
+ last_seen_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
112
+
113
+
114
+ class AgentFrameSubscriber(Component):
115
+ """Projects ``agent_frame`` deliveries to a local JSONL cache.
116
+
117
+ Parameters
118
+ ----------
119
+ bus:
120
+ The shared :class:`EventBus` instance. Used both to subscribe to
121
+ ``identity.event`` (ingress) and to re-publish per-kind frame topics
122
+ (fan-out to hook subscribers).
123
+ frames_path:
124
+ Override the JSONL output path. Tests use this to redirect writes
125
+ to a ``tmp_path`` fixture without touching ``~/.cache/alter/``.
126
+ rotation_threshold_bytes:
127
+ Override the rotation threshold. Tests pass a small value to
128
+ exercise the rotation path without writing megabytes.
129
+ """
130
+
131
+ name = "agent_frames"
132
+
133
+ def __init__(
134
+ self,
135
+ bus: EventBus,
136
+ *,
137
+ frames_path: Path | None = None,
138
+ rotation_threshold_bytes: int = DEFAULT_ROTATION_THRESHOLD_BYTES,
139
+ ) -> None:
140
+ self._bus = bus
141
+ self._frames_path: Path = (
142
+ frames_path if frames_path is not None else cache_dir() / FRAMES_FILENAME
143
+ )
144
+ self._rotation_threshold_bytes = rotation_threshold_bytes
145
+ self._lock = asyncio.Lock()
146
+ self._shutdown_event = asyncio.Event()
147
+ # Bounded LRU dedup window keyed on ``frame_id``. Uses OrderedDict so
148
+ # we can efficiently evict the oldest entry when the window is full.
149
+ self._seen_frame_ids: OrderedDict[str, None] = OrderedDict()
150
+ # In-memory instrument roster — keyed on ``(handle, instrument)`` so
151
+ # that the unix-socket ``agent/roster`` route can return observed
152
+ # instruments without a DO round-trip (D-AGENT-CHANNEL-1 §8).
153
+ self._roster: dict[tuple[str, str], InstrumentRecord] = {}
154
+
155
+ # ------------------------------------------------------------------
156
+ # Component lifecycle
157
+ # ------------------------------------------------------------------
158
+
159
+ async def run(self) -> None:
160
+ """Subscribe to the bus and block until stop() is called.
161
+
162
+ The subscription is registered inside ``run()`` (not ``__init__``) so
163
+ that tests can construct the subscriber without side-effects and call
164
+ :meth:`handle_event` directly.
165
+ """
166
+ self._bus.subscribe("identity.event", self.handle_event)
167
+ logger.info(
168
+ "agent_frames subscriber started frames=%s",
169
+ self._frames_path,
170
+ )
171
+ try:
172
+ await self._shutdown_event.wait()
173
+ except asyncio.CancelledError:
174
+ raise
175
+ finally:
176
+ self._bus.unsubscribe("identity.event", self.handle_event)
177
+ logger.info("agent_frames subscriber stopped")
178
+
179
+ async def stop(self) -> None:
180
+ """Cooperative shutdown — release the run loop."""
181
+ self._shutdown_event.set()
182
+
183
+ # ------------------------------------------------------------------
184
+ # Frame ingest — public surface for the bus and tests
185
+ # ------------------------------------------------------------------
186
+
187
+ async def handle_event(self, event: Any) -> None:
188
+ """Project a single parsed identity-event dict if it is an agent_frame.
189
+
190
+ This is the unit-test seam — the test suite calls this directly with
191
+ synthesised dicts to avoid having to drive a real SSE socket or event
192
+ bus.
193
+ """
194
+ if not isinstance(event, dict):
195
+ return
196
+
197
+ # ---- 1. Filter on content_type ----------------------------------
198
+ if event.get("content_type") != AGENT_CONTENT_TYPE:
199
+ return
200
+
201
+ # ---- 2. Extract frame_id for dedup ------------------------------
202
+ frame_id = event.get("frame_id")
203
+ if not isinstance(frame_id, str) or not frame_id:
204
+ logger.warning(
205
+ "agent_frames: agent_frame missing frame_id - dropping: kind=%r",
206
+ event.get("kind"),
207
+ )
208
+ return
209
+
210
+ # ---- 3. Dedup on frame_id ----------------------------------------
211
+ async with self._lock:
212
+ if frame_id in self._seen_frame_ids:
213
+ logger.debug(
214
+ "agent_frames: duplicate frame_id=%s kind=%r - dropping",
215
+ frame_id,
216
+ event.get("kind"),
217
+ )
218
+ return
219
+
220
+ self._seen_frame_ids[frame_id] = None
221
+ if len(self._seen_frame_ids) > FRAME_DEDUP_WINDOW:
222
+ self._seen_frame_ids.popitem(last=False)
223
+
224
+ # ---- 4. Project the row ---------------------------------------
225
+ line = self._serialise(event)
226
+ if line is None:
227
+ return # already logged
228
+
229
+ # ---- 5. Rotate if oversized -----------------------------------
230
+ try:
231
+ self._maybe_rotate()
232
+ except OSError as exc:
233
+ logger.warning("agent_frames: rotation failed: %s", exc)
234
+ # Continue anyway — better to grow past threshold than drop.
235
+
236
+ # ---- 6. Atomic append -----------------------------------------
237
+ try:
238
+ self._append_line(line)
239
+ except OSError as exc:
240
+ logger.warning("agent_frames: append failed: %s - dropping frame", exc)
241
+ return
242
+
243
+ # ---- 7. Update the in-memory roster ---------------------------
244
+ self._update_roster(event)
245
+
246
+ # ---- 9. Re-publish per-kind bus topic (outside the lock) ---------
247
+ # Re-publish after releasing the lock so hook subscribers can
248
+ # consume the event without risk of deadlocking on the write lock.
249
+ kind = event.get("kind")
250
+ if isinstance(kind, str) and kind:
251
+ topic = TOPIC_AGENT_FRAME_PREFIX + kind
252
+ try:
253
+ await self._bus.publish(topic, event)
254
+ except Exception as exc: # noqa: BLE001 — defensive; bus publish should not raise
255
+ logger.warning(
256
+ "agent_frames: bus publish failed topic=%s: %s",
257
+ topic,
258
+ exc,
259
+ )
260
+
261
+ # ------------------------------------------------------------------
262
+ # Roster
263
+ # ------------------------------------------------------------------
264
+
265
+ def _update_roster(self, event: dict[str, Any]) -> None:
266
+ """Update the in-memory instrument roster from a successfully projected frame.
267
+
268
+ Called inside the write lock so roster state stays consistent with
269
+ the JSONL projection. The roster is best-effort — it only reflects
270
+ instruments that have emitted frames observable to this daemon instance.
271
+ It is NOT a canonical view of all active instruments (that lives in
272
+ the DO/org-alter substrate).
273
+ """
274
+ sender = event.get("sender") or event.get("from_handle") or event.get("sender_handle")
275
+ if not isinstance(sender, str) or not sender:
276
+ return
277
+
278
+ instrument = event.get("instrument") or event.get("tool") or "unknown"
279
+ tool = event.get("tool") or "unknown"
280
+ session_id = event.get("session_id")
281
+ now_iso = datetime.now(timezone.utc).isoformat()
282
+
283
+ key = (sender, str(instrument))
284
+ if key in self._roster:
285
+ rec = self._roster[key]
286
+ # Mutate in-place; dataclass fields are not frozen.
287
+ rec.last_seen_at = now_iso
288
+ if session_id:
289
+ rec.session_id = str(session_id)
290
+ else:
291
+ self._roster[key] = InstrumentRecord(
292
+ handle=sender,
293
+ instrument=str(instrument),
294
+ tool=str(tool),
295
+ session_id=str(session_id) if session_id else None,
296
+ started_at=now_iso,
297
+ last_seen_at=now_iso,
298
+ )
299
+
300
+ def get_roster(self) -> list[dict[str, Any]]:
301
+ """Return the current in-memory roster as a list of dicts.
302
+
303
+ Safe to call from outside the write lock because Python dict iteration
304
+ is GIL-protected for reads and the roster is only mutated inside
305
+ :meth:`_update_roster` (which is called under the write lock in the
306
+ async path). The returned list is a snapshot — callers should not
307
+ cache it.
308
+ """
309
+ return [asdict(rec) for rec in self._roster.values()]
310
+
311
+ # ------------------------------------------------------------------
312
+ # Serialisation
313
+ # ------------------------------------------------------------------
314
+
315
+ def _serialise(self, event: dict[str, Any]) -> str | None:
316
+ """Build the JSONL line for one ``agent_frame`` event.
317
+
318
+ Returns ``None`` if a required field is missing — the caller treats
319
+ that as "drop and continue".
320
+ """
321
+ kind = event.get("kind")
322
+ if not isinstance(kind, str) or not kind:
323
+ logger.warning(
324
+ "agent_frames: agent_frame missing kind - dropping frame_id=%r",
325
+ event.get("frame_id"),
326
+ )
327
+ return None
328
+
329
+ frame_id = event.get("frame_id")
330
+ sender = event.get("sender") or event.get("from_handle") or event.get("sender_handle")
331
+ received_at = (
332
+ event.get("timestamp") or event.get("sent_at") or datetime.now(timezone.utc).isoformat()
333
+ )
334
+ payload = event.get("payload")
335
+
336
+ record: dict[str, Any] = {
337
+ "frame_id": str(frame_id),
338
+ "kind": kind,
339
+ "sender": str(sender) if sender else None,
340
+ "received_at": str(received_at),
341
+ }
342
+
343
+ if payload is not None:
344
+ # Serialise the payload to check its size and guard against bloat.
345
+ try:
346
+ payload_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
347
+ except (TypeError, ValueError) as exc:
348
+ logger.warning(
349
+ "agent_frames: payload not serialisable for frame_id=%s: %s",
350
+ frame_id,
351
+ exc,
352
+ )
353
+ payload_str = None
354
+
355
+ if payload_str is not None:
356
+ payload_bytes = payload_str.encode("utf-8")
357
+ if len(payload_bytes) > MAX_PAYLOAD_BYTES:
358
+ logger.warning(
359
+ "agent_frames: payload exceeds %d bytes for frame_id=%s "
360
+ "- truncating local cache entry",
361
+ MAX_PAYLOAD_BYTES,
362
+ frame_id,
363
+ )
364
+ payload_str = (
365
+ payload_bytes[:MAX_PAYLOAD_BYTES].decode("utf-8", errors="ignore")
366
+ + " [... payload truncated at 64 KiB]"
367
+ )
368
+ record["payload"] = payload_str
369
+ record["payload_truncated"] = True
370
+ else:
371
+ record["payload"] = payload
372
+
373
+ return json.dumps(record, separators=(",", ":"), ensure_ascii=False)
374
+
375
+ # ------------------------------------------------------------------
376
+ # File operations — atomic append, rotation
377
+ # ------------------------------------------------------------------
378
+
379
+ def _ensure_parent(self, path: Path) -> None:
380
+ """Create the parent directory with mode ``0o700`` if missing."""
381
+ parent = path.parent
382
+ if not parent.exists():
383
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
384
+ with contextlib.suppress(OSError):
385
+ os.chmod(parent, 0o700)
386
+
387
+ def _append_line(self, line: str) -> None:
388
+ """Atomically append ``line + '\\n'`` to the cache file.
389
+
390
+ Uses :func:`os.open` with ``O_APPEND | O_CREAT`` and mode ``0o600``,
391
+ followed by an :func:`fcntl.flock` exclusive lock around the write.
392
+ ``O_APPEND`` makes the write itself atomic against concurrent writers
393
+ on POSIX, and ``flock`` serialises us against a hypothetical second
394
+ daemon instance on the same XDG dir.
395
+ """
396
+ self._ensure_parent(self._frames_path)
397
+
398
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
399
+ fd = os.open(self._frames_path, flags, 0o600)
400
+ try:
401
+ with contextlib.suppress(OSError):
402
+ os.fchmod(fd, 0o600)
403
+
404
+ try:
405
+ fcntl.flock(fd, fcntl.LOCK_EX)
406
+ except OSError as exc: # pragma: no cover - exotic FS
407
+ if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
408
+ raise
409
+
410
+ try:
411
+ os.write(fd, line.encode("utf-8") + b"\n")
412
+ os.fsync(fd)
413
+ finally:
414
+ with contextlib.suppress(OSError):
415
+ fcntl.flock(fd, fcntl.LOCK_UN)
416
+ finally:
417
+ os.close(fd)
418
+
419
+ def _maybe_rotate(self) -> None:
420
+ """Rotate the cache file if it exceeds the threshold.
421
+
422
+ Renames ``agent-frames.jsonl`` to ``agent-frames.jsonl.1`` (overwriting
423
+ any existing rotated file). The next :meth:`_append_line` call recreates
424
+ the primary file via ``O_CREAT``. No-op if the file does not yet exist
425
+ or is below the threshold.
426
+ """
427
+ try:
428
+ size = self._frames_path.stat().st_size
429
+ except FileNotFoundError:
430
+ return
431
+ if size <= self._rotation_threshold_bytes:
432
+ return
433
+
434
+ rotated = self._frames_path.parent / FRAMES_ROTATED_FILENAME
435
+ os.replace(self._frames_path, rotated)
436
+ logger.info(
437
+ "agent_frames: rotated %s -> %s (size=%d > threshold=%d)",
438
+ self._frames_path,
439
+ rotated,
440
+ size,
441
+ self._rotation_threshold_bytes,
442
+ )
443
+
444
+ # ------------------------------------------------------------------
445
+ # Test introspection
446
+ # ------------------------------------------------------------------
447
+
448
+ @property
449
+ def frames_path(self) -> Path:
450
+ """JSONL output path (used by tests)."""
451
+ return self._frames_path
452
+
453
+ @property
454
+ def seen_frame_ids(self) -> OrderedDict[str, None]:
455
+ """Current dedup window (used by tests)."""
456
+ return self._seen_frame_ids
457
+
458
+ @property
459
+ def roster(self) -> dict[tuple[str, str], InstrumentRecord]:
460
+ """Current in-memory roster (used by tests)."""
461
+ return self._roster
@@ -0,0 +1,188 @@
1
+ """In-process pub/sub event bus for the alter-runtime daemon.
2
+
3
+ The bus is the coupling point between the three halves of the runtime:
4
+
5
+ * **Ingress subscribers** (``do_sse``, ``mcp_fallback``) publish identity frames
6
+ and connection-state transitions.
7
+ * **Local adapters** (``git_watcher`` and future CC-hook / shell bridges) publish
8
+ outbound ambient signals that the egress producer later relays to the DO.
9
+ * **Local fan-out surfaces** (the Unix socket server, the D-Bus service, the
10
+ InboxWriter projection, cache writers) subscribe to whichever topics they
11
+ care about.
12
+
13
+ Design goals
14
+ ------------
15
+
16
+ 1. **Tiny.** The bus is ~60 lines of Python. It owns no state beyond the
17
+ subscriber map and a lock. No persistence, no ordering guarantees beyond
18
+ "callbacks fire in registration order".
19
+ 2. **Error-isolating.** A buggy subscriber must never kill the publisher's
20
+ task. All exceptions raised inside a subscriber callback are caught and
21
+ logged - the next subscriber still runs, and the publisher's `publish()`
22
+ call returns normally.
23
+ 3. **Async-first.** Both the publisher and subscribers run inside the daemon's
24
+ single asyncio loop. Callbacks may be ``async`` coroutines or plain
25
+ callables; both are supported.
26
+ 4. **No topic taxonomy enforcement.** Topics are free-form strings. Conventions
27
+ live in the daemon (currently: ``identity.frame``, ``identity.event``,
28
+ ``identity.connected``, ``identity.disconnected``, ``local.signal``). Tests
29
+ that want to assert on topic names can do so against these conventions.
30
+
31
+ Topic registry (agent channel — D-AGENT-CHANNEL-1 Phase 2 Wave 6)
32
+ ------------------------------------------------------------------
33
+
34
+ ``alter.agent.frame.{kind}`` — published by :class:`AgentFrameSubscriber`
35
+ once per successfully projected ``agent_frame`` delivery whose
36
+ ``content_type`` equals ``"x-alter-agent"``. The ``{kind}`` suffix is the
37
+ 15-kind catalogue value from D-AGENT-CHANNEL-1 §4.C (Phase 2 Wave 1 widened
38
+ ``kind`` to a 15-member ``Literal``; the 12→15 extension absorbs the three
39
+ convergence kinds, ratified via the catalogue-extension decision), e.g.::
40
+
41
+ alter.agent.frame.agent_handover
42
+ alter.agent.frame.agent_advisory
43
+ alter.agent.frame.agent_broadcast
44
+ alter.agent.frame.agent_lock_request
45
+ alter.agent.frame.agent_lock_release
46
+ alter.agent.frame.agent_query
47
+ alter.agent.frame.agent_response
48
+ alter.agent.frame.agent_lease_extend
49
+ alter.agent.frame.agent_binding_moment
50
+ alter.agent.frame.agent_return_event
51
+ alter.agent.frame.peer_diagnostic_request
52
+ alter.agent.frame.peer_diagnostic_response
53
+ alter.agent.frame.intent_declare
54
+ alter.agent.frame.intent_withdraw
55
+ alter.agent.frame.flush_executed
56
+
57
+ The payload published on each topic is the full deserialised ``agent_frame``
58
+ dict as received from the DO SSE stream. Hook subscribers (CC hooks,
59
+ adapters) can filter by subscribing to the specific kind topics they care
60
+ about rather than reading from the JSONL cache on every event.
61
+
62
+ The bus is deliberately not a drop-in replacement for a durable message broker.
63
+ If the daemon crashes or a subscriber is slow, events are lost. The durable
64
+ state lives in the DO (replayable via ``Last-Event-ID``) and in the backend
65
+ audit DB; the bus is a transient coupling layer only.
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import asyncio
71
+ import inspect
72
+ import logging
73
+ from collections.abc import Awaitable, Callable
74
+ from typing import Any, Union
75
+
76
+ __all__ = ["EventBus", "Subscriber"]
77
+
78
+ logger = logging.getLogger("alter_runtime.subscribers.bus")
79
+
80
+ #: A subscriber callback may be sync or async. The bus awaits async callbacks
81
+ #: and calls sync callbacks inline.
82
+ Subscriber = Union[
83
+ Callable[[Any], None],
84
+ Callable[[Any], Awaitable[None]],
85
+ ]
86
+
87
+
88
+ class EventBus:
89
+ """Topic-keyed asyncio pub/sub.
90
+
91
+ Thread-safety: all methods must be called from the same asyncio loop.
92
+ The bus does not attempt to be thread-safe.
93
+ """
94
+
95
+ def __init__(self) -> None:
96
+ self._subscribers: dict[str, list[Subscriber]] = {}
97
+ self._lock = asyncio.Lock()
98
+
99
+ # ------------------------------------------------------------------
100
+ # Subscribe / unsubscribe
101
+ # ------------------------------------------------------------------
102
+
103
+ def subscribe(self, topic: str, callback: Subscriber) -> None:
104
+ """Register a callback to receive events published to ``topic``.
105
+
106
+ Multiple callbacks may subscribe to the same topic; they fire in
107
+ registration order. Calling ``subscribe`` for the same ``(topic,
108
+ callback)`` pair twice registers it twice - the caller is expected
109
+ to keep track of its own subscriptions and ``unsubscribe`` symmetrically.
110
+ """
111
+ self._subscribers.setdefault(topic, []).append(callback)
112
+ logger.debug(
113
+ "bus subscribe topic=%s callback=%r count=%d",
114
+ topic,
115
+ _describe_callback(callback),
116
+ len(self._subscribers[topic]),
117
+ )
118
+
119
+ def unsubscribe(self, topic: str, callback: Subscriber) -> None:
120
+ """Remove a previously registered callback.
121
+
122
+ No-op if the callback is not registered or the topic has no
123
+ subscribers - callers should not need to check first.
124
+ """
125
+ callbacks = self._subscribers.get(topic)
126
+ if not callbacks:
127
+ return
128
+ try:
129
+ callbacks.remove(callback)
130
+ except ValueError:
131
+ return
132
+ if not callbacks:
133
+ self._subscribers.pop(topic, None)
134
+
135
+ # ------------------------------------------------------------------
136
+ # Publish
137
+ # ------------------------------------------------------------------
138
+
139
+ async def publish(self, topic: str, payload: Any) -> None:
140
+ """Dispatch ``payload`` to every subscriber of ``topic``.
141
+
142
+ Subscribers run sequentially in registration order. Exceptions raised
143
+ by any one subscriber are caught and logged - the remainder still
144
+ run, and ``publish`` returns normally once all have been invoked.
145
+ """
146
+ # Snapshot the subscriber list so that a subscriber mutating its own
147
+ # subscription mid-publish doesn't corrupt the iteration.
148
+ callbacks = list(self._subscribers.get(topic, ()))
149
+ if not callbacks:
150
+ return
151
+
152
+ for callback in callbacks:
153
+ try:
154
+ result = callback(payload)
155
+ if inspect.isawaitable(result):
156
+ await result
157
+ except asyncio.CancelledError:
158
+ raise
159
+ except Exception as exc:
160
+ logger.warning(
161
+ "bus publish topic=%s subscriber=%r raised: %s - continuing",
162
+ topic,
163
+ _describe_callback(callback),
164
+ exc,
165
+ )
166
+
167
+ # ------------------------------------------------------------------
168
+ # Test introspection
169
+ # ------------------------------------------------------------------
170
+
171
+ def subscriber_count(self, topic: str) -> int:
172
+ """Return the number of active subscribers on ``topic``."""
173
+ return len(self._subscribers.get(topic, ()))
174
+
175
+ def topics(self) -> list[str]:
176
+ """Return the list of topics that currently have subscribers."""
177
+ return list(self._subscribers.keys())
178
+
179
+
180
+ def _describe_callback(callback: Subscriber) -> str:
181
+ """Best-effort human-readable name for a callback, for log lines."""
182
+ try:
183
+ name = getattr(callback, "__qualname__", None) or getattr(callback, "__name__", None)
184
+ if name:
185
+ return name
186
+ return repr(callback)
187
+ except Exception: # pragma: no cover - defensive
188
+ return "<callback>"