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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""ActiveSessionsGc - periodic sweeper for the active-sessions JSONL.
|
|
2
|
+
|
|
3
|
+
Companion to :class:`ActiveSessionsWriter`. The writer projects
|
|
4
|
+
``session_started`` / ``session_heartbeat`` / ``session_ended`` bus events
|
|
5
|
+
into ``~/.local/share/alter-runtime/active-sessions.jsonl``, but nothing
|
|
6
|
+
sweeps the live state to detect idle/dead sessions and emit the
|
|
7
|
+
corresponding lifecycle transitions. This component fills that gap.
|
|
8
|
+
|
|
9
|
+
Behaviour
|
|
10
|
+
---------
|
|
11
|
+
|
|
12
|
+
On every tick (default 60s):
|
|
13
|
+
|
|
14
|
+
1. Read the JSONL under ``LOCK_SH`` shared lock (writer uses ``LOCK_EX``
|
|
15
|
+
so this serialises correctly without blocking writes), fold to
|
|
16
|
+
``{(tool, session_id) -> newest row by last_activity}``, skip rows
|
|
17
|
+
already terminal (``status == "complete"``).
|
|
18
|
+
2. For each ``(tool, session_id)``:
|
|
19
|
+
|
|
20
|
+
* If ``(now - last_activity) > idle_after_seconds`` and the source row
|
|
21
|
+
is not already ``status == "idle"``, emit a ``session_heartbeat``
|
|
22
|
+
with ``status="idle"`` and ``version = source.version + 1``.
|
|
23
|
+
* If ``(now - last_activity) > terminated_after_seconds``:
|
|
24
|
+
|
|
25
|
+
- For ``tool == "cc"``: probe ``os.kill(int(session_id), 0)`` and
|
|
26
|
+
only emit if the PID is dead (catches ``ProcessLookupError``,
|
|
27
|
+
``PermissionError``, ``OSError``).
|
|
28
|
+
- For other tools: emit on threshold alone (no PID semantics).
|
|
29
|
+
- Emit a ``session_ended`` with ``status="complete"``.
|
|
30
|
+
|
|
31
|
+
3. Track an internal ``_emitted_versions`` map keyed on
|
|
32
|
+
``(tool, session_id)`` -> ``(last_status, version)`` so repeated ticks
|
|
33
|
+
over the same stale source do not re-emit the same envelope every
|
|
34
|
+
cycle.
|
|
35
|
+
|
|
36
|
+
The component NEVER writes the JSONL directly - every emit goes through
|
|
37
|
+
the shared :class:`EventBus`, so the writer's ``_VALID_KINDS`` gate, its
|
|
38
|
+
``(id, version)`` dedup, rotation, and 0o600 file mode are preserved
|
|
39
|
+
transparently. New envelope ``id``s are minted per emit; the dedup
|
|
40
|
+
contract on the writer side is that newer ``version``s for the same
|
|
41
|
+
``id`` win - here we always carry forward the source row's ``id`` and
|
|
42
|
+
bump ``version`` so the writer accepts the new envelope as the next
|
|
43
|
+
generation of the same session record.
|
|
44
|
+
|
|
45
|
+
Per D-COORD-D2 Wave B - the daemon-side GC pass.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import asyncio
|
|
51
|
+
import contextlib
|
|
52
|
+
import errno
|
|
53
|
+
import fcntl
|
|
54
|
+
import json
|
|
55
|
+
import logging
|
|
56
|
+
import os
|
|
57
|
+
import uuid
|
|
58
|
+
from datetime import datetime, timezone
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
61
|
+
|
|
62
|
+
from alter_runtime.config import DaemonConfig, data_dir
|
|
63
|
+
from alter_runtime.daemon import Component
|
|
64
|
+
from alter_runtime.subscribers.active_sessions_writer import (
|
|
65
|
+
ACTIVE_SESSIONS_FILENAME,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
70
|
+
|
|
71
|
+
__all__ = ["ActiveSessionsGc"]
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger("alter_runtime.subscribers.active_sessions_gc")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _default_pid_probe(pid: int) -> bool:
|
|
77
|
+
"""Return ``True`` if ``pid`` is alive, ``False`` otherwise.
|
|
78
|
+
|
|
79
|
+
``os.kill(pid, 0)`` is the canonical liveness probe on POSIX:
|
|
80
|
+
|
|
81
|
+
* Returns silently when the process exists and the caller is
|
|
82
|
+
permitted to signal it.
|
|
83
|
+
* Raises ``ProcessLookupError`` when no such PID exists - the
|
|
84
|
+
definitive "dead" signal.
|
|
85
|
+
* Raises ``PermissionError`` when the PID exists but is owned by a
|
|
86
|
+
different user - treat as "alive" (we cannot reap a process we do
|
|
87
|
+
not own).
|
|
88
|
+
* Raises ``OSError(errno.ESRCH)`` on some exotic kernels - same as
|
|
89
|
+
``ProcessLookupError``.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
os.kill(pid, 0)
|
|
93
|
+
except ProcessLookupError:
|
|
94
|
+
return False
|
|
95
|
+
except PermissionError:
|
|
96
|
+
return True
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
if exc.errno == errno.ESRCH:
|
|
99
|
+
return False
|
|
100
|
+
# Any other error class is treated conservatively as "alive" so a
|
|
101
|
+
# transient probe failure does not surface as a spurious
|
|
102
|
+
# session_ended.
|
|
103
|
+
return True
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ActiveSessionsGc(Component):
|
|
108
|
+
"""Periodic sweeper that emits idle / terminated session envelopes.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
config:
|
|
113
|
+
Loaded :class:`DaemonConfig`. Reads
|
|
114
|
+
``active_sessions_gc_interval_seconds``,
|
|
115
|
+
``active_sessions_idle_after_seconds``,
|
|
116
|
+
``active_sessions_terminated_after_seconds``.
|
|
117
|
+
bus:
|
|
118
|
+
Shared :class:`EventBus`. Emits via ``identity.event``.
|
|
119
|
+
sessions_path:
|
|
120
|
+
Override the JSONL path. Tests redirect reads to ``tmp_path``.
|
|
121
|
+
pid_probe:
|
|
122
|
+
Override the PID-liveness probe. Tests inject a stub so they do
|
|
123
|
+
not depend on the host's real process table.
|
|
124
|
+
now:
|
|
125
|
+
Override the clock. Tests pass a frozen ``datetime`` provider.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
name = "active_sessions_gc"
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
config: DaemonConfig,
|
|
133
|
+
bus: EventBus,
|
|
134
|
+
*,
|
|
135
|
+
sessions_path: Path | None = None,
|
|
136
|
+
pid_probe: Callable[[int], bool] | None = None,
|
|
137
|
+
now: Callable[[], datetime] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._config = config
|
|
140
|
+
self._bus = bus
|
|
141
|
+
self._sessions_path: Path = (
|
|
142
|
+
sessions_path if sessions_path is not None else data_dir() / ACTIVE_SESSIONS_FILENAME
|
|
143
|
+
)
|
|
144
|
+
self._pid_probe: Callable[[int], bool] = pid_probe or _default_pid_probe
|
|
145
|
+
self._now: Callable[[], datetime] = now or (lambda: datetime.now(timezone.utc))
|
|
146
|
+
|
|
147
|
+
self._stop_event = asyncio.Event()
|
|
148
|
+
# Tracks the last (status, version) we emitted for a given
|
|
149
|
+
# (tool, session_id) so we never re-emit the same idle/complete
|
|
150
|
+
# envelope on a subsequent tick.
|
|
151
|
+
self._emitted_versions: dict[tuple[str, str], tuple[str, int]] = {}
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Component lifecycle
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async def run(self) -> None:
|
|
158
|
+
logger.info(
|
|
159
|
+
"active_sessions_gc starting sessions=%s interval=%.1fs "
|
|
160
|
+
"idle_after=%.1fs terminated_after=%.1fs",
|
|
161
|
+
self._sessions_path,
|
|
162
|
+
self._config.active_sessions_gc_interval_seconds,
|
|
163
|
+
self._config.active_sessions_idle_after_seconds,
|
|
164
|
+
self._config.active_sessions_terminated_after_seconds,
|
|
165
|
+
)
|
|
166
|
+
try:
|
|
167
|
+
while not self._stop_event.is_set():
|
|
168
|
+
try:
|
|
169
|
+
await self._tick()
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
raise
|
|
172
|
+
except Exception as exc: # noqa: BLE001 - last-resort safety net
|
|
173
|
+
logger.exception("active_sessions_gc tick failed: %s", exc)
|
|
174
|
+
await self._sleep_interruptible(self._config.active_sessions_gc_interval_seconds)
|
|
175
|
+
finally:
|
|
176
|
+
logger.info("active_sessions_gc stopped")
|
|
177
|
+
|
|
178
|
+
async def stop(self) -> None:
|
|
179
|
+
self._stop_event.set()
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Tick
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def _tick(self) -> None:
|
|
186
|
+
"""One sweep: read live state, emit idle/terminated where due."""
|
|
187
|
+
rows = self._read_live_rows()
|
|
188
|
+
if not rows:
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
live = self._fold_newest(rows)
|
|
192
|
+
now = self._now()
|
|
193
|
+
idle_after = self._config.active_sessions_idle_after_seconds
|
|
194
|
+
terminated_after = self._config.active_sessions_terminated_after_seconds
|
|
195
|
+
|
|
196
|
+
for key, source in live.items():
|
|
197
|
+
tool, session_id = key
|
|
198
|
+
last_activity_raw = source.get("last_activity")
|
|
199
|
+
if not isinstance(last_activity_raw, str) or not last_activity_raw:
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
last_activity = datetime.fromisoformat(last_activity_raw.replace("Z", "+00:00"))
|
|
203
|
+
except ValueError:
|
|
204
|
+
continue
|
|
205
|
+
if last_activity.tzinfo is None:
|
|
206
|
+
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
|
207
|
+
|
|
208
|
+
age = (now - last_activity).total_seconds()
|
|
209
|
+
if age <= 0:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
current_status = source.get("status")
|
|
213
|
+
current_version = source.get("version")
|
|
214
|
+
if not isinstance(current_version, int):
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# ----- 1. Terminated (overrides idle) -------------------
|
|
218
|
+
if age > terminated_after:
|
|
219
|
+
if tool == "cc":
|
|
220
|
+
pid = self._parse_pid(session_id)
|
|
221
|
+
if pid is None:
|
|
222
|
+
# Non-numeric CC session id - cannot probe, skip
|
|
223
|
+
# the terminated emit (the row will keep ageing
|
|
224
|
+
# but we never reap what we cannot probe).
|
|
225
|
+
continue
|
|
226
|
+
if self._pid_probe(pid):
|
|
227
|
+
continue
|
|
228
|
+
await self._emit(
|
|
229
|
+
source,
|
|
230
|
+
kind="session_ended",
|
|
231
|
+
new_status="complete",
|
|
232
|
+
now=now,
|
|
233
|
+
)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# ----- 2. Idle ------------------------------------------
|
|
237
|
+
if age > idle_after and current_status != "idle":
|
|
238
|
+
await self._emit(
|
|
239
|
+
source,
|
|
240
|
+
kind="session_heartbeat",
|
|
241
|
+
new_status="idle",
|
|
242
|
+
now=now,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Emit
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
async def _emit(
|
|
250
|
+
self,
|
|
251
|
+
source: dict[str, Any],
|
|
252
|
+
*,
|
|
253
|
+
kind: str,
|
|
254
|
+
new_status: str,
|
|
255
|
+
now: datetime,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Publish a new envelope via the bus iff we haven't already."""
|
|
258
|
+
tool = source.get("tool")
|
|
259
|
+
session_id = source.get("session_id")
|
|
260
|
+
if not isinstance(tool, str) or not isinstance(session_id, str):
|
|
261
|
+
return
|
|
262
|
+
key = (tool, session_id)
|
|
263
|
+
|
|
264
|
+
next_version = int(source["version"]) + 1
|
|
265
|
+
prior = self._emitted_versions.get(key)
|
|
266
|
+
if prior is not None:
|
|
267
|
+
prior_status, prior_version = prior
|
|
268
|
+
if prior_status == new_status and prior_version >= next_version:
|
|
269
|
+
# We already emitted this transition; nothing to do.
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
payload: dict[str, Any] = {
|
|
273
|
+
"id": source.get("id") or str(uuid.uuid4()),
|
|
274
|
+
"version": next_version,
|
|
275
|
+
"kind": kind,
|
|
276
|
+
"handle": source.get("handle"),
|
|
277
|
+
"tool": tool,
|
|
278
|
+
"session_id": session_id,
|
|
279
|
+
"machine_id": source.get("machine_id"),
|
|
280
|
+
"started_at": source.get("started_at"),
|
|
281
|
+
"last_activity": now.isoformat(),
|
|
282
|
+
"status": new_status,
|
|
283
|
+
"consent_tier": source.get("consent_tier"),
|
|
284
|
+
"provenance_class": source.get("provenance_class", "active_composition"),
|
|
285
|
+
}
|
|
286
|
+
# Optional fields - carry forward only when present.
|
|
287
|
+
if "working_on" in source:
|
|
288
|
+
payload["working_on"] = source.get("working_on")
|
|
289
|
+
if "files_touched" in source:
|
|
290
|
+
payload["files_touched"] = source.get("files_touched")
|
|
291
|
+
|
|
292
|
+
# Reassign envelope id to a fresh value per the design lock:
|
|
293
|
+
# writer dedup is by (id, version), but here we are minting a NEW
|
|
294
|
+
# envelope that represents a new lifecycle transition. Carrying
|
|
295
|
+
# the source id forward bumps the same record; minting a fresh
|
|
296
|
+
# id makes the GC emit independently observable. The brief
|
|
297
|
+
# spec'd ``str(uuid.uuid4())`` - honour that.
|
|
298
|
+
payload["id"] = str(uuid.uuid4())
|
|
299
|
+
|
|
300
|
+
await self._bus.publish("identity.event", payload)
|
|
301
|
+
self._emitted_versions[key] = (new_status, next_version)
|
|
302
|
+
logger.debug(
|
|
303
|
+
"active_sessions_gc emitted kind=%s status=%s tool=%s session=%s version=%d",
|
|
304
|
+
kind,
|
|
305
|
+
new_status,
|
|
306
|
+
tool,
|
|
307
|
+
session_id,
|
|
308
|
+
next_version,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
# File reading
|
|
313
|
+
# ------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
def _read_live_rows(self) -> list[dict[str, Any]]:
|
|
316
|
+
"""Read every line of the JSONL under a shared lock.
|
|
317
|
+
|
|
318
|
+
Returns an empty list when the file is missing or unreadable.
|
|
319
|
+
"""
|
|
320
|
+
if not self._sessions_path.exists():
|
|
321
|
+
return []
|
|
322
|
+
|
|
323
|
+
rows: list[dict[str, Any]] = []
|
|
324
|
+
flags = os.O_RDONLY
|
|
325
|
+
try:
|
|
326
|
+
fd = os.open(self._sessions_path, flags)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
logger.warning("active_sessions_gc: open failed: %s", exc)
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
try:
|
|
333
|
+
fcntl.flock(fd, fcntl.LOCK_SH)
|
|
334
|
+
except OSError as exc: # pragma: no cover - exotic FS
|
|
335
|
+
if exc.errno not in (errno.ENOTSUP, errno.EINVAL):
|
|
336
|
+
logger.warning("active_sessions_gc: LOCK_SH failed: %s", exc)
|
|
337
|
+
return []
|
|
338
|
+
try:
|
|
339
|
+
buf = b""
|
|
340
|
+
while True:
|
|
341
|
+
chunk = os.read(fd, 65536)
|
|
342
|
+
if not chunk:
|
|
343
|
+
break
|
|
344
|
+
buf += chunk
|
|
345
|
+
finally:
|
|
346
|
+
with contextlib.suppress(OSError):
|
|
347
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
348
|
+
finally:
|
|
349
|
+
os.close(fd)
|
|
350
|
+
|
|
351
|
+
for raw_line in buf.decode("utf-8", errors="replace").splitlines():
|
|
352
|
+
line = raw_line.strip()
|
|
353
|
+
if not line:
|
|
354
|
+
continue
|
|
355
|
+
try:
|
|
356
|
+
row = json.loads(line)
|
|
357
|
+
except (ValueError, json.JSONDecodeError):
|
|
358
|
+
continue
|
|
359
|
+
if isinstance(row, dict):
|
|
360
|
+
rows.append(row)
|
|
361
|
+
return rows
|
|
362
|
+
|
|
363
|
+
def _fold_newest(self, rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
|
|
364
|
+
"""Fold rows to ``{(tool, session_id): newest non-tombstone row}``.
|
|
365
|
+
|
|
366
|
+
``status == "complete"`` is the tombstone - once a session ends
|
|
367
|
+
we never want to resurrect it as idle/terminated.
|
|
368
|
+
"""
|
|
369
|
+
folded: dict[tuple[str, str], dict[str, Any]] = {}
|
|
370
|
+
for row in rows:
|
|
371
|
+
tool = row.get("tool")
|
|
372
|
+
session_id = row.get("session_id")
|
|
373
|
+
if not isinstance(tool, str) or not isinstance(session_id, str):
|
|
374
|
+
continue
|
|
375
|
+
key = (tool, session_id)
|
|
376
|
+
current = folded.get(key)
|
|
377
|
+
if current is None:
|
|
378
|
+
folded[key] = row
|
|
379
|
+
continue
|
|
380
|
+
if self._compare_last_activity(row, current) > 0:
|
|
381
|
+
folded[key] = row
|
|
382
|
+
|
|
383
|
+
# Drop tombstones - sessions already complete are not GC's
|
|
384
|
+
# business.
|
|
385
|
+
return {key: row for key, row in folded.items() if row.get("status") != "complete"}
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _compare_last_activity(a: dict[str, Any], b: dict[str, Any]) -> int:
|
|
389
|
+
"""Return >0 if ``a`` is newer than ``b``, <0 if older, 0 equal."""
|
|
390
|
+
a_ts = a.get("last_activity") or ""
|
|
391
|
+
b_ts = b.get("last_activity") or ""
|
|
392
|
+
if a_ts == b_ts:
|
|
393
|
+
# Tie-breaker on version so a heartbeat at the same ISO
|
|
394
|
+
# timestamp beats the prior start.
|
|
395
|
+
a_v = a.get("version") if isinstance(a.get("version"), int) else -1
|
|
396
|
+
b_v = b.get("version") if isinstance(b.get("version"), int) else -1
|
|
397
|
+
return (a_v or -1) - (b_v or -1)
|
|
398
|
+
return 1 if a_ts > b_ts else -1
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _parse_pid(session_id: str) -> int | None:
|
|
402
|
+
"""Return ``session_id`` as ``int`` if it parses, else ``None``."""
|
|
403
|
+
try:
|
|
404
|
+
pid = int(session_id)
|
|
405
|
+
except (TypeError, ValueError):
|
|
406
|
+
return None
|
|
407
|
+
return pid if pid > 0 else None
|
|
408
|
+
|
|
409
|
+
# ------------------------------------------------------------------
|
|
410
|
+
# Helpers
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
async def _sleep_interruptible(self, seconds: float) -> None:
|
|
414
|
+
"""Sleep ``seconds`` or until stop is set, whichever comes first."""
|
|
415
|
+
if seconds <= 0:
|
|
416
|
+
return
|
|
417
|
+
try:
|
|
418
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=seconds)
|
|
419
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# Test introspection
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def sessions_path(self) -> Path:
|
|
428
|
+
return self._sessions_path
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def emitted_versions(self) -> dict[tuple[str, str], tuple[str, int]]:
|
|
432
|
+
return dict(self._emitted_versions)
|