Coverage for astrocyte/harness/agentd.py: 93%
512 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""``astrocyte agentd`` — warm memory for agent hooks.
3Why a daemon: a hook is a fresh process per event. Measured on an Apple
4Silicon laptop, the first recall in a fresh process costs ~1.1 s (mostly
5importing fastembed), against 8 ms in a warm one — and ``UserPromptSubmit``
6blocks the user's prompt until the hook returns. So hooks talk to this
7per-user process and never load a model themselves.
9Transport: a Unix domain socket in the state directory (0600: filesystem
10permissions are the authentication). Where there is none (Windows; or forced
11with ``ASTROCYTE_AGENTD_TRANSPORT=tcp``), TCP on 127.0.0.1 at a port the OS
12picks, published with a random token in ``agentd.json`` in the state
13directory; a request without that token gets no reply.
15Lifecycle: started on demand by the hooks, one instance per user (a lock file),
16exits after ``ASTROCYTE_AGENTD_IDLE`` seconds without a request (default
171800) or as soon as its config file changes or the package is upgraded, so the
18next hook restarts it on the new config and code. Captures arrive through a durable on-disk spool, so a daemon
19that is down or restarting loses nothing.
21Relevance gating: recall's fused ``score`` is a ranking value, not a
22similarity — an unrelated prompt ("weather in Paris") outranked related ones
23in testing. Injection is therefore gated on the cosine between the prompt and
24each candidate, computed here with the warm model. Calibrated on
25bge-small-en-v1.5: relevant pairs scored ≥ 0.661 (long captured turns) and
26≥ 0.706 (short facts); unrelated prompts scored ≤ 0.609. Defaults sit in that
27gap; ``ASTROCYTE_INJECT_MIN_SIMILARITY`` overrides.
28"""
30from __future__ import annotations
32import argparse
33import contextlib
34import hmac
35import json
36import logging
37import logging.handlers
38import math
39import os
40import re
41import secrets
42import socket
43import subprocess
44import sys
45import time
46import uuid
47from datetime import datetime, timezone
48from pathlib import Path
49from typing import TYPE_CHECKING, Any
51if TYPE_CHECKING:
52 import asyncio
54from .paths import agentd_endpoint, agentd_socket, config_path, spool_dir, state_dir
56logger = logging.getLogger("astrocyte.agentd")
58INJECT_MIN_SIMILARITY = float(os.environ.get("ASTROCYTE_INJECT_MIN_SIMILARITY", "0.65"))
59# Keep only hits close to the best one: weaker neighbours that clear the
60# absolute bar were, in calibration, other topics from the same session.
61INJECT_RELATIVE_WINDOW = 0.08
62PROMPT_MAX_HITS = 4
63PROMPT_MAX_CHARS = 1_800
64BOOT_MAX_ITEMS = 8
65BOOT_MAX_CHARS = 3_000
66ITEM_MAX_CHARS = 360
67# Where you left off: the closing turns of the project's previous session.
68# The last answer gets the most room: that is where conclusions and next steps are.
69# Opt-in file recall: earlier turns that touched the file the agent just
70# read or edited (their `files` metadata, recorded at capture).
71FILE_MAX_TURNS = 3
72FILE_SCAN = 400
73RESUME_MAX_TURNS = 3
74RESUME_LAST_MAX_CHARS = 700
75RESUME_SCAN = 60
76SOURCE_LABELS = {"claude-code": "Claude Code", "codex": "Codex", "antigravity": "Antigravity",
77 "copilot": "Copilot CLI"}
78DRAIN_INTERVAL_SECONDS = 20.0
79#: Background team sync (`astrocyte team join`): every joined project, this often.
80TEAM_SYNC_SECONDS = float(os.environ.get("ASTROCYTE_TEAM_SYNC_SECONDS", "300"))
81IDLE_EXIT_SECONDS = float(os.environ.get("ASTROCYTE_AGENTD_IDLE", "1800"))
83# The conversation pipeline stores turns with single newlines between roles,
84# whatever separator capture wrote, so match any run of whitespace.
85_TURN = re.compile(r"^\*\*user\*\*:\s*(?P<q>.*?)\s*\*\*assistant\*\*:\s*(?P<a>.*)$", re.DOTALL)
88TRANSPORT_ENV = "ASTROCYTE_AGENTD_TRANSPORT"
91def transport() -> str:
92 """``unix`` where there are Unix domain sockets, else ``tcp`` (loopback +
93 token). ``ASTROCYTE_AGENTD_TRANSPORT=tcp`` forces TCP, so the Windows
94 path is exercised on every platform."""
95 unix = hasattr(socket, "AF_UNIX") and os.name != "nt"
96 forced = os.environ.get(TRANSPORT_ENV, "").strip().lower()
97 return "tcp" if forced == "tcp" or not unix else "unix"
100def supported() -> bool:
101 """Can hooks reach a daemon here? Every platform has a transport; kept so
102 callers can still ask (and tests can say no)."""
103 return True
106# ── rendering ────────────────────────────────────────────────────────────
109def _clip(text: str, limit: int) -> str:
110 text = " ".join(text.split())
111 return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
114def who_saved(metadata: Any) -> str | None:
115 """A teammate's name from the ``_actor`` the gateway stamped: ``user:alice``
116 reads as ``alice``; other principals (``service:ci``) as they are."""
117 actor = (metadata or {}).get("_actor")
118 if not isinstance(actor, str) or not actor:
119 return None
120 kind, _, name = actor.partition(":")
121 return name if kind == "user" and name else actor
124def _line(body: str, when: datetime | None, who: str | None) -> str:
125 return "- " + (f"[{when.date().isoformat()}] " if when else "") + (f"({who}) " if who else "") + body
128def render_memory(text: str, when: datetime | None, *, limit: int = ITEM_MAX_CHARS, who: str | None = None) -> str:
129 """One line per memory. Captured turns become "Q … → A …"; the answer
130 gets most of the room because that is where conclusions live. ``who``
131 names the teammate a memory came from; your own carry no label."""
132 m = _TURN.match(text.strip())
133 if m:
134 q = _clip(m["q"], 110)
135 body = f"Q: {q} → A: {_clip(m['a'], limit - len(q) - 12)}"
136 else:
137 body = _clip(text, limit)
138 return _line(body, when, who)
141def _clip_left(text: str, limit: int) -> str:
142 """The end of ``text``: where a long answer's conclusion is."""
143 text = " ".join(text.split())
144 if len(text) <= limit:
145 return text
146 tail = text[-(limit - 1):]
147 return "…" + (tail.split(" ", 1)[1] if " " in tail[:40] else tail)
150def _join_overlapping(a: str, b: str) -> str:
151 """``a`` then ``b``, written once where the chunker made them overlap."""
152 for k in range(min(len(a), len(b)), 15, -1):
153 if a.endswith(b[:k]):
154 return a + b[k:]
155 return f"{a} {b}" if a else b
158def _groups(items: list[Any]) -> list[list[Any]]:
159 """Memories regrouped into what was retained together, newest first.
161 A long captured turn is stored as several chunks (the question, then
162 overlapping pieces of the answer) that share one ``_retain_id`` and come
163 back in no useful order; ``_chunk_index`` gives the order they were written."""
164 groups: dict[str, list[Any]] = {}
165 for item in items:
166 meta = item.metadata or {}
167 # _retain_id since 0.18; _created_at alone for memories stored before
168 # (it can merge retains made within one clock tick).
169 key = meta.get("_retain_id") or meta.get("_created_at") or item.id
170 groups.setdefault(str(key), []).append(item)
171 out = []
172 for group in groups.values():
173 # _chunk_index where stored (since 0.18); retained_at for older chunks.
174 group.sort(key=lambda i: ((i.metadata or {}).get("_chunk_index", 0), i.retained_at is None,
175 i.retained_at or 0))
176 out.append(group)
177 return out
180def render_group(group: list[Any], when: datetime | None, *, limit: int = ITEM_MAX_CHARS,
181 who: str | None = None) -> str:
182 """One line for memories retained together. A chunked turn shows its
183 question and the end of its answer; other text, its beginning."""
184 if len(group) == 1:
185 return render_memory(group[0].text, when, limit=limit, who=who)
186 question = next((i.text for i in group if i.text.lstrip().startswith("**user**")), None)
187 if question is None:
188 return render_memory(group[0].text, when, limit=limit, who=who)
189 m = _TURN.match(question.strip())
190 q = _clip(m["q"] if m else question.split(":", 1)[-1], 110)
191 answer = ""
192 for item in group:
193 if item.text.lstrip().startswith("**user**"):
194 answer = m["a"] if m else ""
195 else:
196 answer = _join_overlapping(answer, item.text.strip())
197 answer = re.sub(r"^\*\*assistant\*\*:\s*", "", answer.strip())
198 body = f"Q: {q} → A: {_clip_left(answer, limit - len(q) - 12)}"
199 return _line(body, when, who)
202def _budget(lines: list[str], limit: int) -> list[str]:
203 out, used = [], 0
204 for line in lines:
205 if used + len(line) + 1 > limit:
206 break
207 out.append(line)
208 used += len(line) + 1
209 return out
212def _ago(when: datetime, now: datetime | None = None) -> str:
213 if when.tzinfo is None:
214 when = when.replace(tzinfo=timezone.utc)
215 minutes = int(((now or datetime.now(timezone.utc)) - when).total_seconds() // 60)
216 if minutes < 2:
217 return "just now"
218 if minutes < 60:
219 return f"{minutes} minutes ago"
220 if minutes < 48 * 60:
221 hours = minutes // 60
222 return f"{hours} hour{'s' if hours != 1 else ''} ago"
223 return f"{minutes // (24 * 60)} days ago"
226def _cosine(a: list[float], b: list[float]) -> float:
227 dot = sum(x * y for x, y in zip(a, b))
228 na = math.sqrt(sum(x * x for x in a))
229 nb = math.sqrt(sum(y * y for y in b))
230 return dot / (na * nb) if na and nb else 0.0
233# ── daemon ───────────────────────────────────────────────────────────────
236def installed_version() -> str | None:
237 """The astrocyte version installed now, read from disk: an upgrade
238 replaces it under a running daemon, whose code stays the old one."""
239 import importlib
240 import importlib.metadata
242 importlib.invalidate_caches()
243 try:
244 return importlib.metadata.version("astrocyte")
245 except importlib.metadata.PackageNotFoundError:
246 return None
249class AgentDaemon:
250 def __init__(self, cfg_path: Path) -> None:
251 from .memories import open_local
253 self.cfg_path = cfg_path
254 self.cfg_mtime = cfg_path.stat().st_mtime
255 self.version = installed_version()
256 # No recall-time query expansion (an LLM call of 5–9 s via a CLI
257 # provider; a hook on the prompt path cannot afford it) and no
258 # observation consolidation (an LLM call per captured turn in the
259 # background — the user's subscription, unasked; a capture burst
260 # queued 32 of them).
261 self.pipeline, self.brain = open_local(cfg_path)
262 self.injected: dict[str, set[str]] = {}
263 self.files_offered: dict[str, set[str]] = {} # session → paths already answered
264 self.token: str | None = None # set when serving over TCP
265 self.last_activity = time.monotonic()
266 self.next_team_sync = 0.0 # due at the first housekeeping pass
267 self._sync_brain: Any = None
268 self._teammates: dict[str, tuple[float, set[str]]] = {} # bank → (state mtime, pulled ids)
269 import asyncio # daemon side only: hook processes import this module too
271 self._drain_lock = asyncio.Lock()
272 self._stop = asyncio.Event()
274 async def warm(self) -> None:
275 """Load the embedding model before accepting requests, so the first
276 prompt of a session is as fast as the rest."""
277 await self.pipeline.llm_provider.embed(["warm"])
279 # ── ops ───────────────────────────────────────────────────────────
281 async def op_ping(self, _: dict) -> dict:
282 return {"ok": True, "pid": os.getpid(), "config": str(self.cfg_path)}
284 def teammates(self, bank: str) -> set[str]:
285 """Ids of memories pulled from teammates (re-read when sync changes them)."""
286 from .team import SyncState
288 try:
289 mtime = SyncState.path(bank).stat().st_mtime
290 except OSError:
291 return set()
292 cached = self._teammates.get(bank)
293 if cached is None or cached[0] != mtime:
294 cached = (mtime, set(SyncState.load(bank).pulled))
295 self._teammates[bank] = cached
296 return cached[1]
298 def _who(self, group: list[Any], teammates: set[str]) -> str | None:
299 return who_saved(group[0].metadata) if group[0].id in teammates else None
301 async def _previous_session(self, bank: str, session: str) -> list[list[Any]]:
302 """The closing captured turns (oldest first) of the newest session in
303 ``bank`` other than ``session``. Sessions running side by side
304 interleave, so other sessions' turns are skipped, not a stop."""
305 from astrocyte.types import VectorFilters
307 recent = getattr(self.pipeline.vector_store, "list_recent_vectors", None)
308 if recent is None:
309 return []
310 previous, turns = None, []
311 teammates = self.teammates(bank) # where *you* left off: a teammate's shared turns aren't it
312 items = await recent(bank, limit=RESUME_SCAN, filters=VectorFilters(tags=["captured"]))
313 for group in _groups(items):
314 sid = (group[0].metadata or {}).get("session_id") or ""
315 if not sid or sid == session or (previous is not None and sid != previous):
316 continue
317 if any(i.id in teammates for i in group):
318 continue
319 previous = sid
320 turns.append(group)
321 if len(turns) == RESUME_MAX_TURNS:
322 break
323 return turns[::-1]
325 async def op_boot(self, req: dict) -> dict:
326 bank, session = req["bank"], req.get("session_id") or ""
327 if req.get("source") in ("clear", "compact"):
328 self.injected.pop(session, None) # earlier injections left the context
329 self.files_offered.pop(session, None)
330 seen = self.injected.setdefault(session, set())
331 sections: list[str] = []
332 used = 0
333 resume = await self._previous_session(bank, session)
334 if resume:
335 last = resume[-1][-1]
336 when = last.occurred_at or last.retained_at
337 agent = SOURCE_LABELS.get(str((last.metadata or {}).get("source") or ""), "an agent")
338 lines = [render_group(g, None, limit=RESUME_LAST_MAX_CHARS if g is resume[-1] else ITEM_MAX_CHARS)
339 for g in resume]
340 sections.append(f"Where you left off: the previous session here ({agent}"
341 + (f", {_ago(when)}" if when else "") + ") ended with:\n" + "\n".join(lines))
342 used = len(sections[0])
343 shown = {item.id for g in resume for item in g}
344 seen |= shown
345 recent_groups: list[list[Any]] = []
346 recent = getattr(self.pipeline.vector_store, "list_recent_vectors", None)
347 if recent is not None:
348 items = await recent(bank, limit=(BOOT_MAX_ITEMS + RESUME_MAX_TURNS) * 4)
349 recent_groups = [g for g in _groups(items) if not shown & {i.id for i in g}][:BOOT_MAX_ITEMS] # not twice
350 teammates = self.teammates(bank)
351 body = _budget([render_group(g, g[0].occurred_at or g[0].retained_at, who=self._who(g, teammates))
352 for g in recent_groups], BOOT_MAX_CHARS - used)
353 seen.update(i.id for g in recent_groups[: len(body)] for i in g) # only what was shown counts as injected
354 header = (
355 f"Astrocyte memory is active for this project (bank `{bank}`). Relevant memories from "
356 "earlier sessions are added to your context automatically; save durable decisions, "
357 "conventions and preferences with the memory_retain tool."
358 )
359 if not body and not sections:
360 return {"context": header + " No memories for this project yet."}
361 if body:
362 sections.append("Most recent memories:\n" + "\n".join(body))
363 return {"context": header + "\n\n" + "\n\n".join(sections)}
365 async def op_recall(self, req: dict) -> dict:
366 bank, session, prompt = req["bank"], req.get("session_id") or "", req["prompt"]
367 result = await self.brain.recall(prompt, bank_id=bank, max_results=8)
368 hits = [h for h in result.hits if h.text]
369 if not hits:
370 return {"context": ""}
371 vectors = await self.pipeline.llm_provider.embed([prompt] + [h.text for h in hits])
372 scored = sorted(((_cosine(vectors[0], v), h) for v, h in zip(vectors[1:], hits)), key=lambda p: -p[0])
373 best = scored[0][0]
374 seen = self.injected.setdefault(session, set())
375 teammates = self.teammates(bank)
376 lines = []
377 for sim, hit in scored:
378 if sim < INJECT_MIN_SIMILARITY or sim < best - INJECT_RELATIVE_WINDOW:
379 break
380 key = hit.memory_id or hit.text
381 if key in seen:
382 continue
383 who = who_saved(hit.metadata) if hit.memory_id in teammates else None
384 lines.append(render_memory(hit.text, hit.occurred_at or hit.retained_at, who=who))
385 seen.add(key)
386 if len(lines) == PROMPT_MAX_HITS:
387 break
388 lines = _budget(lines, PROMPT_MAX_CHARS)
389 if not lines:
390 return {"context": "", "best_similarity": round(best, 3)}
391 text = "Possibly relevant memories from earlier sessions (Astrocyte):\n" + "\n".join(lines)
392 return {"context": text, "best_similarity": round(best, 3)}
394 async def op_file(self, req: dict) -> dict:
395 """Earlier captured turns that read or edited ``path``, newest first;
396 each file once per session, and nothing already in context."""
397 from astrocyte.types import VectorFilters
399 bank, session, path = req["bank"], req.get("session_id") or "", req["path"]
400 offered = self.files_offered.setdefault(session, set())
401 recent = getattr(self.pipeline.vector_store, "list_recent_vectors", None)
402 if path in offered or recent is None:
403 return {"context": ""}
404 offered.add(path)
405 seen = self.injected.setdefault(session, set())
406 groups = []
407 for group in _groups(await recent(bank, limit=FILE_SCAN, filters=VectorFilters(tags=["captured"]))):
408 touched = {f for item in group for f in str((item.metadata or {}).get("files") or "").splitlines()}
409 if path in touched and not any(item.id in seen for item in group):
410 groups.append(group)
411 if len(groups) == FILE_MAX_TURNS:
412 break
413 teammates = self.teammates(bank)
414 body = _budget([render_group(g, g[0].occurred_at or g[0].retained_at, who=self._who(g, teammates))
415 for g in groups], PROMPT_MAX_CHARS)
416 if not body:
417 return {"context": ""}
418 seen.update(item.id for g in groups[: len(body)] for item in g)
419 return {"context": f"Earlier sessions that touched {path} (Astrocyte):\n" + "\n".join(body)}
421 async def op_capture(self, _: dict) -> dict:
422 import asyncio
424 asyncio.get_running_loop().create_task(self.drain())
425 return {"ok": True}
427 # ── capture spool ─────────────────────────────────────────────────
429 async def drain(self) -> int:
430 """Retain every spooled turn. Progress is written back after each
431 turn, so a crash mid-file resumes without duplicating memories."""
432 stored = 0
433 async with self._drain_lock:
434 for path in sorted(spool_dir().glob("*.json")):
435 try:
436 batch = json.loads(path.read_text(encoding="utf-8"))
437 turns = list(batch["turns"])
438 bank = batch["bank"]
439 except (OSError, ValueError, KeyError, TypeError):
440 failed = spool_dir() / "failed"
441 failed.mkdir(exist_ok=True)
442 with contextlib.suppress(OSError):
443 path.rename(failed / path.name)
444 logger.warning("moved unreadable spool file %s aside", path.name)
445 continue
446 while turns:
447 turn = turns[0]
448 started = turn.get("started_at")
449 result = await self.brain.retain(
450 turn["content"],
451 bank_id=bank,
452 content_type="conversation",
453 occurred_at=datetime.fromisoformat(started) if started else None,
454 metadata={"session_id": batch.get("session_id") or "", "source": batch.get("source") or "",
455 # Files the turn read or edited, one per line (metadata is flat).
456 **({"files": "\n".join(turn["files"])} if turn.get("files") else {})},
457 tags=["captured"],
458 source=batch.get("source") or "agent",
459 )
460 if not (getattr(result, "stored", False) or getattr(result, "deduplicated", False)):
461 logger.error("retain failed for %s: %s", path.name, getattr(result, "error", "?"))
462 return stored # leave the rest for the next drain
463 stored += 1
464 turns.pop(0)
465 if turns:
466 _atomic_write(path, {**batch, "turns": turns})
467 path.unlink(missing_ok=True)
468 if stored:
469 logger.info("captured %d turn(s)", stored)
470 return stored
472 # ── server ────────────────────────────────────────────────────────
474 async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
475 import asyncio
477 self.last_activity = time.monotonic()
478 try:
479 line = await asyncio.wait_for(reader.readline(), timeout=5)
480 req = json.loads(line or b"{}")
481 if self.token is not None and not hmac.compare_digest(str(req.pop("token", "")), self.token):
482 logger.warning("refused a request without the daemon's token")
483 writer.close()
484 return
485 op = getattr(self, f"op_{req.get('op', '')}", None)
486 reply = await op(req) if op else {"error": f"unknown op {req.get('op')!r}"}
487 except Exception as e: # noqa: BLE001 — a bad request must not kill the daemon
488 logger.exception("request failed")
489 reply = {"error": f"{type(e).__name__}: {e}"}
490 with contextlib.suppress(Exception):
491 writer.write((json.dumps(reply) + "\n").encode())
492 await writer.drain()
493 writer.close()
495 async def team_sync(self) -> None:
496 """Push and pull every joined project (`astrocyte team join`). A sync
497 already running elsewhere is skipped; failures are logged and kept in
498 the project's sync state for `astrocyte team status`."""
499 from . import team
501 memberships = team.load_memberships(self.cfg_path)
502 if not memberships:
503 return
504 if self._sync_brain is None:
505 # The daemon's own brain refuses a burst of writes as a noisy bank;
506 # a first pull of a team's memories is exactly such a burst.
507 from astrocyte import Astrocyte
508 from astrocyte.config import load_config
510 from .memories import without_noisy_bank_detection
512 self._sync_brain = Astrocyte(without_noisy_bank_detection(load_config(str(self.cfg_path))))
513 self._sync_brain.set_pipeline(self.pipeline)
514 for bank, entry in memberships.items():
515 try:
516 report = await team.sync_bank(entry, bank, self.pipeline, self._sync_brain)
517 logger.info("team sync %s: %s", bank, report.summary())
518 except team.SyncBusy:
519 logger.info("team sync %s: skipped, another sync is running", bank)
520 except team.TeamError as e:
521 logger.warning("team sync %s failed: %s", bank, e)
522 except Exception: # noqa: BLE001 — never let one project's sync stop the daemon
523 logger.exception("team sync %s failed", bank)
525 async def housekeeping(self) -> None:
526 import asyncio
528 while not self._stop.is_set():
529 with contextlib.suppress(asyncio.TimeoutError):
530 await asyncio.wait_for(self._stop.wait(), timeout=DRAIN_INTERVAL_SECONDS)
531 if self._stop.is_set():
532 break
533 try:
534 await self.drain()
535 except Exception: # noqa: BLE001
536 logger.exception("drain failed")
537 if time.monotonic() >= self.next_team_sync:
538 self.next_team_sync = time.monotonic() + TEAM_SYNC_SECONDS
539 await self.team_sync()
540 try:
541 changed = self.cfg_path.stat().st_mtime != self.cfg_mtime
542 except OSError:
543 changed = True
544 if changed:
545 logger.info("config changed; exiting so the next hook restarts on it")
546 self._stop.set()
547 elif (now := installed_version()) != self.version:
548 logger.info("astrocyte %s -> %s; exiting so the next hook restarts on it", self.version, now)
549 self._stop.set()
550 elif time.monotonic() - self.last_activity > IDLE_EXIT_SECONDS:
551 logger.info("idle; exiting")
552 self._stop.set()
554 async def serve(self, sock_path: Path) -> None:
555 import asyncio
557 await self.warm()
558 if transport() == "tcp":
559 self.token = secrets.token_urlsafe(32)
560 server = await asyncio.start_server(self.handle, host="127.0.0.1", port=0)
561 port = server.sockets[0].getsockname()[1]
562 endpoint = agentd_endpoint()
563 _write_private(endpoint, {"transport": "tcp", "port": port, "token": self.token, "pid": os.getpid()})
564 logger.info("serving on 127.0.0.1:%d (pid %d)", port, os.getpid())
565 cleanup = endpoint
566 else:
567 with contextlib.suppress(FileNotFoundError):
568 sock_path.unlink() # stale: we hold the instance lock
569 server = await asyncio.start_unix_server(self.handle, path=str(sock_path))
570 os.chmod(sock_path, 0o600)
571 logger.info("serving on %s (pid %d)", sock_path, os.getpid())
572 cleanup = sock_path
573 await self.drain() # anything captured while no daemon was running
574 async with server:
575 await self.housekeeping()
576 with contextlib.suppress(FileNotFoundError):
577 cleanup.unlink()
580def _replace(src: Path, dst: Path) -> None:
581 """``os.replace``, retried briefly: on Windows it fails while another
582 process (a hook reading state, a virus scanner) has ``dst`` open."""
583 for attempt in range(4):
584 try:
585 os.replace(src, dst)
586 return
587 except PermissionError:
588 if attempt == 3:
589 raise
590 time.sleep(0.05)
593def _write_private(path: Path, data: dict) -> None:
594 """Atomically, readable by the owner only (0600 on POSIX; on Windows the
595 state directory's profile ACLs)."""
596 path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
597 tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
598 fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
599 with os.fdopen(fd, "w", encoding="utf-8") as fh:
600 json.dump(data, fh)
601 _replace(tmp, path)
604def _atomic_write(path: Path, data: dict) -> None:
605 tmp = path.with_name(path.name + ".tmp")
606 tmp.write_text(json.dumps(data), encoding="utf-8")
607 _replace(tmp, path)
610def spool_capture(bank: str, session_id: str, source: str, turns: list[dict]) -> Path:
611 """Durably queue turns for the daemon (atomic rename, 0600)."""
612 d = spool_dir()
613 d.mkdir(parents=True, exist_ok=True, mode=0o700)
614 # Time first so the drain keeps order; the random tail because a coarse
615 # clock (Windows: ~15 ms) gives two captures in one tick the same name,
616 # and the second would replace the first.
617 path = d / f"{time.time_ns()}-{os.getpid()}-{uuid.uuid4().hex[:8]}.json"
618 tmp = path.with_suffix(".tmp")
619 fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
620 with os.fdopen(fd, "w", encoding="utf-8") as fh:
621 json.dump({"bank": bank, "session_id": session_id, "source": source, "turns": turns}, fh)
622 os.replace(tmp, path)
623 return path
626# ── client ───────────────────────────────────────────────────────────────
629def request(op: str, payload: dict[str, Any] | None = None, *, timeout: float = 2.0) -> dict | None:
630 """One request to the daemon; None if it isn't reachable in time."""
631 if not supported():
632 return None
633 message: dict[str, Any] = {"op": op, **(payload or {})}
634 try:
635 if transport() == "tcp":
636 endpoint = json.loads(agentd_endpoint().read_text(encoding="utf-8"))
637 message["token"] = endpoint["token"]
638 s = socket.create_connection(("127.0.0.1", int(endpoint["port"])), timeout=timeout)
639 else:
640 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
641 s.settimeout(timeout)
642 s.connect(str(agentd_socket()))
643 with s:
644 s.sendall((json.dumps(message) + "\n").encode())
645 buf = b""
646 while not buf.endswith(b"\n"):
647 chunk = s.recv(65536)
648 if not chunk:
649 break
650 buf += chunk
651 return json.loads(buf) if buf else None
652 except (OSError, ValueError, KeyError, TypeError):
653 return None
656def spawn(cfg: Path) -> None:
657 """Start the daemon detached from the hook (and from the agent session),
658 unless one was started moments ago."""
659 if not supported():
660 return
661 d = state_dir()
662 d.mkdir(parents=True, exist_ok=True, mode=0o700)
663 marker = d / "agentd.spawned"
664 with contextlib.suppress(OSError):
665 if time.time() - marker.stat().st_mtime < 5:
666 return
667 marker.touch()
668 # Not agentd.log: the daemon rotates that, and Windows can't rename a file
669 # another handle holds open. This catches what precedes logging (a crash on import).
670 log = open(d / "agentd.stdio.log", "ab") # noqa: SIM115 — handed to the child process
671 argv = [sys.executable, "-I", "-m", "astrocyte.harness.agentd", "--config", str(cfg)]
672 try:
673 if os.name == "nt":
674 # No console window; out of the hook's process group; and out of the
675 # agent's job object where allowed, or the daemon dies with the session.
676 flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
677 try:
678 subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True,
679 creationflags=flags | subprocess.CREATE_BREAKAWAY_FROM_JOB)
680 except OSError: # the job forbids breakaway: live as long as the session
681 subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True,
682 creationflags=flags)
683 else:
684 subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True,
685 close_fds=True)
686 finally:
687 log.close()
690def ensure_running(cfg: Path, *, wait: float) -> bool:
691 if request("ping", timeout=0.3):
692 return True
693 spawn(cfg)
694 deadline = time.monotonic() + wait
695 while time.monotonic() < deadline:
696 time.sleep(0.15)
697 if request("ping", timeout=0.3):
698 return True
699 return False
702# ── entry point ──────────────────────────────────────────────────────────
705def _lock_exclusively(fh: Any) -> bool:
706 """Take the single-instance lock without waiting; False if it's held."""
707 try:
708 if os.name == "nt":
709 import msvcrt
711 msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
712 else:
713 import fcntl
715 fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
716 except OSError:
717 return False
718 return True
721def run(cfg: Path) -> int:
722 if not supported():
723 print("astrocyte agentd: no local transport on this platform.", file=sys.stderr)
724 return 1
725 d = state_dir()
726 d.mkdir(parents=True, exist_ok=True, mode=0o700)
727 handler = logging.handlers.RotatingFileHandler(d / "agentd.log", maxBytes=1_000_000, backupCount=2)
728 handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
729 logging.basicConfig(level=logging.INFO, handlers=[handler])
731 lock = open(d / "agentd.lock", "w") # noqa: SIM115 — held for the process lifetime
732 if not _lock_exclusively(lock):
733 return 0 # another daemon owns the socket
734 import asyncio
736 try:
737 asyncio.run(AgentDaemon(cfg).serve(agentd_socket()))
738 except Exception: # noqa: BLE001
739 logger.exception("agentd crashed")
740 return 1
741 return 0
744def main(argv: list[str] | None = None) -> int:
745 p = argparse.ArgumentParser(prog="astrocyte agentd", description=__doc__.splitlines()[0])
746 p.add_argument("--config", default=None)
747 args = p.parse_args(argv)
748 return run(Path(args.config).expanduser() if args.config else config_path())
751if __name__ == "__main__":
752 raise SystemExit(main())