Files
hermes-agent/tools/bot_live_delivery.py
ethernet 9f2ba1b74d merge origin/main (779 commits) into ethie/pm-clean
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).

Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.

uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
2026-09-21 00:58:39 -04:00

347 lines
15 KiB
Python

"""Durable, at-most-once handoff to an existing Bot Chat owner.
Adapted from FalconOrtiz's live-owner mailbox (#101564). A single private
record advances queued -> claimed -> terminal under a process-shared lock.
Claims never expire: a crashed consumer leaves an inspectable unknown outcome,
not permission to execute the same input again. Receipts are permanent.
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import uuid
from contextlib import contextmanager
from utils import atomic_json_write, atomic_write_text, fsync_directory
from pathlib import Path
from typing import Any, Callable
from hermes_cli.active_sessions import _FileLock
log = logging.getLogger(__name__)
DELIVERY_DIR_NAME = "bot_live_delivery"
_SEQUENCE_FILE = ".sequence"
_OWNER_KEYS = ("profile_home", "session_id", "lease_id", "live_session_id")
_TERMINAL = frozenset({"settled", "failed", "cancelled", "ambiguous"})
def find_canonical_owner(profile_home: Path | str) -> dict[str, Any] | None:
"""Return the exact Bot Chat tip's lease, including unsupported CLI owners."""
from hermes_cli.active_sessions import active_session_registry_snapshot
from hermes_state import SessionDB
home = Path(profile_home).resolve()
if not (home / "state.db").is_file():
return None
db = SessionDB(db_path=home / "state.db", read_only=True)
try:
row = db.get_session_by_title("Bot Chat")
session_id = db.get_compression_tip(row["id"]) if row else None
finally:
db.close()
if not session_id:
return None
for entry in active_session_registry_snapshot(registry_home=home):
if entry["session_id"] == session_id:
return {**entry, "profile_home": str(home)}
return None
def find_canonical_live_owner(profile_home: Path | str) -> dict[str, Any] | None:
"""Only advertised consumers may receive owner-pinned mailbox deliveries."""
entry = find_canonical_owner(profile_home)
meta = (entry or {}).get("metadata") or {}
if entry and meta.get("bot_live_delivery_consumer") is True and meta.get("live_session_id"):
return {key: entry[key] for key in ("profile_home", "session_id", "lease_id")} | {
"live_session_id": meta["live_session_id"]}
return None
def _owner(home: Path | str, owner: dict[str, Any]) -> dict[str, str]:
pinned = {key: owner.get(key) for key in _OWNER_KEYS}
if not all(isinstance(value, str) and value for value in pinned.values()):
raise ValueError("owner requires profile_home, session_id, lease_id and live_session_id")
if pinned["profile_home"] != str(Path(home).resolve()):
raise ValueError("owner belongs to a different profile home")
return pinned
def _delivery_id(value: str) -> str:
if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{32,64}", value) is None:
raise ValueError("delivery id must be 32 to 64 lowercase hex characters")
return value
def _root(home: Path | str) -> Path:
return Path(home).resolve() / "runtime" / DELIVERY_DIR_NAME
def has_mailbox(profile_home: Path | str) -> bool:
"""Whether any delivery was ever admitted for this profile (the mailbox directory is created on
first admission only). A cheap pre-check for pollers: no mailbox means nothing to claim, so the
owner lookup — a state.db open plus the exclusive active-session registry lock — can be skipped."""
return _root(profile_home).is_dir()
@contextmanager
def _locked(home: Path | str):
root = _root(home)
created = not root.is_dir()
root.parent.mkdir(parents=True, exist_ok=True)
root.mkdir(mode=0o700, exist_ok=True)
root.chmod(0o700)
if created:
# Only a fresh mailbox dir needs its parents durably linked; the live
# poller re-enters this lock twice a second per profile, and two
# directory fsyncs per idle poll was measurable disk churn for nothing.
fsync_directory(root.parent)
fsync_directory(root.parent.parent)
lock = root / ".lock"
fd = os.open(lock, os.O_CREAT | os.O_WRONLY, 0o600)
os.close(fd)
with _FileLock(lock):
yield root
def _read(path: Path) -> dict[str, Any] | None:
"""Exact-id read: absent → None; unreadable or not a JSON object → raises (callers fail closed)."""
try:
record = json.loads(path.read_text(encoding="utf-8-sig"))
except FileNotFoundError:
return None
if not isinstance(record, dict):
raise ValueError(f"ticket {path.name} is not a JSON object ({type(record).__name__})")
return record
# Tickets already reported unreadable by this process. The live poller rescans the
# dir twice a second, so a persistent bad ticket is WARNING once and DEBUG after.
_warned_unreadable: set[Path] = set()
def _ticket_shape_error(path: Path, record: dict[str, Any]) -> str | None:
"""Why a parsed ticket is unusable by the scans, or None when it is well-formed.
A ticket that parses as JSON but lost a field (truncated rewrite, foreign
writer, hand edit) used to raise KeyError/TypeError out of the sequence
scan and the claim sweep — wedging admission and delivery for the whole
profile exactly like corrupt JSON did before ``_scan_read`` existed.
"""
owner = record.get("owner")
created_at, sequence = record.get("created_at"), record.get("sequence", record.get("created_at"))
if record.get("delivery_id") != path.stem or record.get("id") != path.stem:
return "id does not match filename"
status = record.get("status")
if not isinstance(status, str) or status not in ({"queued", "claimed"} | _TERMINAL):
return f"unknown status {status!r}"
if not all(isinstance(v, int) and not isinstance(v, bool) for v in (created_at, sequence)):
return "created_at/sequence are not integers"
if not isinstance(owner, dict) or not all(isinstance(owner.get(k), str) and owner[k] for k in _OWNER_KEYS):
return "owner pin is incomplete"
return None
def _scan_read(path: Path) -> dict[str, Any] | None:
"""Bulk-scan variant: one unreadable or malformed ticket must not wedge the whole dir.
Directory scans (sequence high-water mark, queued-claim sweep) may only
treat a file as absent when it is provably absent; an unreadable ticket
degrades to "that one delivery is uninspectable" with a warning.
Exact-id reads (admission idempotency, completion, result lookup) keep
using _read so a permission error still fails closed instead of
licensing an overwrite of a possibly-live receipt.
"""
try:
record = _read(path)
problem = None if record is None else _ticket_shape_error(path, record)
except (OSError, ValueError) as exc: # ValueError: corrupt JSON and invalid UTF-8 alike
record, problem = None, str(exc)
if problem is not None:
level = logging.DEBUG if path in _warned_unreadable else logging.WARNING
_warned_unreadable.add(path)
log.log(level, "bot_live_delivery: skipping unreadable ticket %s (%s)", path.name, problem)
return None
_warned_unreadable.discard(path)
return record
def _next_sequence(root: Path) -> int:
"""Allocate the next admission sequence under the dir lock.
The high-water mark lives in a counter file beside the tickets, so a ticket
the scan cannot read does not drop its sequence and hand a later admission
a duplicate or lower one. Readable tickets still bootstrap dirs written
before the counter existed. Wall time can roll back; sequences never do.
"""
counter = root / _SEQUENCE_FILE
try:
persisted = int(counter.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
persisted = 0
scanned = max((record.get("sequence", record["created_at"])
for candidate in root.glob("*.json")
if (record := _scan_read(candidate)) is not None), default=0)
sequence = max(persisted, scanned) + 1
atomic_write_text(counter, str(sequence), mode=0o600, fsync_dir=True)
return sequence
def _write(path: Path, record: dict[str, Any]) -> None:
atomic_json_write(path, record, indent=None, sort_keys=True, fsync_dir=True, mode=0o600)
def deliver_to_live_owner(
profile_home: Path | str, owner: dict[str, Any], message: str,
*, delivery_id: str | None = None, author: dict[str, Any] | None = None,
notification_category: str = "result",
) -> dict[str, Any]:
"""Return durable admission immediately, without waiting for the owner.
Retry with the same id AND pinned owner/message to inspect the existing
state. Reusing an id with a different payload is an error, never an overwrite.
"""
pinned = _owner(profile_home, owner)
if not isinstance(message, str):
raise ValueError("message must be a string")
key = _delivery_id(delivery_id if delivery_id is not None else uuid.uuid4().hex)
with _locked(profile_home) as root:
path = root / f"{key}.json"
existing = _read(path)
if existing is not None:
if (existing["owner"] != pinned or existing["message"] != message or existing.get("author") != author
or existing.get("notification_category", "result") != notification_category):
raise ValueError("delivery id already belongs to a different payload")
return existing
record = dict(delivery_id=key, id=key, owner=pinned, **pinned,
message=message, status="queued", created_at=time.time_ns(),
sequence=_next_sequence(root), **({"author": dict(author)} if author else {}))
if notification_category == "diagnostic":
record["notification_category"] = notification_category
_write(path, record)
return record
def _matches(home: Path | str, record: dict, owner: dict) -> bool:
pinned = record["owner"]
if any(pinned[key] != owner[key] for key in ("profile_home", "lease_id", "live_session_id")):
return False
if pinned["session_id"] == owner["session_id"]:
return True
from hermes_state import SessionDB
db = SessionDB(db_path=Path(home) / "state.db", read_only=True)
try:
return db.get_compression_tip(pinned["session_id"]) == owner["session_id"]
finally:
db.close()
def claim_pending_delivery(
profile_home: Path | str, owner: dict[str, Any],
) -> dict[str, Any] | None:
"""Claim oldest matching input exactly once; caller supplies its current lease.
A lease transfer across compression is accepted only along the original
stored session's compression chain. A new lease/live session cannot steal it.
Caller must hold its normal turn-admission guard before invoking this.
"""
current = _owner(profile_home, owner)
if not _root(profile_home).is_dir():
return None
with _locked(profile_home) as root:
pending = []
for path in root.glob("*.json"):
record = _scan_read(path)
if record is not None and record["status"] == "queued" and _matches(profile_home, record, current):
pending.append(record)
if not pending:
return None
record = min(pending, key=lambda item: (
item.get("sequence", item["created_at"]), item["delivery_id"]))
record.update(status="claimed", claimed_at=time.time_ns())
_write(root / f"{record['delivery_id']}.json", record)
return record
def complete_delivery(
profile_home: Path | str, delivery_id: str, *, status: str,
reply: str = "", error: str = "", reason: str = "",
) -> dict[str, Any]:
"""Persist an immutable terminal receipt; duplicate identical completion is safe."""
key = _delivery_id(delivery_id)
if status not in _TERMINAL:
raise ValueError("invalid terminal delivery status")
outcome = dict(status=status, reply=reply, error=error, reason=reason)
with _locked(profile_home) as root:
path = root / f"{key}.json"
record = _read(path)
if record is None:
raise FileNotFoundError(f"delivery not found: {key}")
if record["status"] in _TERMINAL:
if any(record.get(k) != v for k, v in outcome.items()):
raise ValueError("delivery already has a different terminal receipt")
return record
if record["status"] != "claimed":
raise ValueError("delivery must be claimed before completion")
record.update(outcome, completed_at=time.time_ns())
_write(path, record)
return record
def read_delivery_result(profile_home: Path | str, delivery_id: str) -> dict[str, Any] | None:
"""Read admission/claim/terminal state without waiting or deleting its receipt."""
return _read(_root(profile_home) / f"{_delivery_id(delivery_id)}.json")
_PENDING = ("queued", "claimed")
_POLL_SECONDS = 0.5
def await_delivery(
profile_home: Path | str, delivery_id: str, timeout: float | None,
*, should_stop: Callable[[], bool] | None = None,
) -> dict[str, Any] | None:
"""Poll a receipt until the owner settles it, ``timeout`` lapses, or ``should_stop`` says so.
Every transport that hands a turn to a live Bot Chat owner (local ``message_agent``, the
Desktop relay, ``hermes peer dm`` and ``hermes peer run``) waits on the same receipt; keeping
the loop here is what stops the lanes drifting (one lane returned a receipt sentence instead
of the reply, two never waited at all). Returns the last record read — still pending when the
budget lapsed, None when the receipt was never readable.
"""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
record = read_delivery_result(profile_home, delivery_id)
if record is None or record["status"] not in _PENDING:
return record
if should_stop is not None and should_stop():
return record
remaining = None if deadline is None else deadline - time.monotonic()
if remaining is not None and remaining <= 0:
return record
time.sleep(_POLL_SECONDS if remaining is None else min(_POLL_SECONDS, remaining))
async def await_delivery_async(
profile_home: Path | str, delivery_id: str, timeout: float | None,
*, should_stop: Callable[[], bool] | None = None,
) -> dict[str, Any] | None:
"""``await_delivery`` for an event loop: never blocks a worker thread for the whole budget."""
import asyncio
deadline = None if timeout is None else time.monotonic() + timeout
while True:
record = await asyncio.to_thread(read_delivery_result, profile_home, delivery_id)
if record is None or record["status"] not in _PENDING:
return record
if should_stop is not None and should_stop():
return record
remaining = None if deadline is None else deadline - time.monotonic()
if remaining is not None and remaining <= 0:
return record
await asyncio.sleep(_POLL_SECONDS if remaining is None else min(_POLL_SECONDS, remaining))