608 lines
24 KiB
Python
608 lines
24 KiB
Python
"""Bot Mode cross-connection relay — connections ARE the peer set.
|
|
|
|
Gateway-side half of the relay that lets agents on ANY Desktop-connected
|
|
gateway (local, remote URL, SSH, Hermes Cloud, docker) message agents on ANY
|
|
other, with ``message_agent`` as the one send path — connections ARE the peer
|
|
set. Plain file plumbing under ``<root>/bot_relay/`` — no network; the gateway
|
|
never holds another connection's credentials, the Desktop owns every socket and
|
|
does all cross-connection I/O:
|
|
|
|
- ``roster.json`` — union roster of agents on OTHER connections, pushed by the
|
|
Desktop (``bot_relay.roster.sync``); folded into the Bot Chat protocol section
|
|
and used to resolve cross-connection targets.
|
|
- ``outbox/`` — envelopes queued by ``message_agent``; the Desktop drains them
|
|
(``bot_relay.outbox.drain``) and delivers on the target connection.
|
|
- ``replies/`` — one JSON per envelope (``bot_relay.reply``); a background
|
|
waiter spawned at send time watches it so the reply wakes the sender via the
|
|
same completion-notification path local DMs use.
|
|
|
|
Public helpers never raise, except ``enqueue_envelope`` → ``EnvelopeRefusedError``
|
|
when the target is definitively offline (fail fast instead of queueing a DM
|
|
nobody will drain).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Iterator, Optional
|
|
|
|
from tools.bot_mode_probe import _default_home, _hermes_root
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
RELAY_DIR_NAME = "bot_relay"
|
|
ROSTER_FILE = "roster.json"
|
|
OUTBOX_DIR = "outbox"
|
|
CLAIMED_DIR = "claimed"
|
|
REPLIES_DIR = "replies"
|
|
LOCKS_DIR = "locks"
|
|
|
|
# Fallback wait budget for a queued delivery turn when config is unreadable
|
|
# (real knob: ``bot_mode.turn_wait_seconds``).
|
|
TURN_WAIT_SECONDS_FALLBACK = 120
|
|
|
|
# Waiter give-up budget. Cross-connection turns can be slow (remote model,
|
|
# cold gateway) — generous, but bounded.
|
|
REPLY_WAIT_SECONDS = 900
|
|
|
|
# Envelopes/replies older than this are stale artifacts (Desktop closed,
|
|
# connection died) and are swept opportunistically.
|
|
STALE_AFTER_SECONDS = 6 * 3600
|
|
|
|
# Fallback envelope TTL when config is unreachable — mirrors the
|
|
# ``bot_mode.envelope_ttl_seconds`` default. Older envelopes are refused at
|
|
# drain time with a 'queued_expired' error reply instead of delivered late.
|
|
DEFAULT_ENVELOPE_TTL_SECONDS = 900
|
|
|
|
# A roster older than this proves nothing about who is offline: the Desktop
|
|
# re-pushes roster.sync on connection-state changes, so only a recent roster
|
|
# is authoritative for the fail-fast check.
|
|
ROSTER_FRESH_SECONDS = 600
|
|
|
|
|
|
class EnvelopeRefusedError(RuntimeError):
|
|
"""``enqueue_envelope`` refused to queue — nothing was written to disk.
|
|
|
|
``reason`` is a stable machine code ('runtime_offline'); ``str(exc)`` is the
|
|
human text.
|
|
"""
|
|
|
|
def __init__(self, reason: str, message: str):
|
|
super().__init__(message)
|
|
self.reason = reason
|
|
|
|
|
|
# Profile names, handles and connection ids share one shape (also the local
|
|
# ``message_agent`` target grammar in ``tools/bot_mode_dm.py``).
|
|
_HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
|
|
|
|
# One turn in a profile's canonical Bot Chat: ``hermes -p <profile> *BOT_CHAT_TURN_ARGS``.
|
|
# ``-c "Bot Chat"`` must match ``bot_mode_probe.BOT_CHAT_TITLE``.
|
|
BOT_CHAT_TURN_ARGS = ("chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q")
|
|
|
|
|
|
def relay_root(root: Path | str) -> Path:
|
|
return Path(root) / RELAY_DIR_NAME
|
|
|
|
|
|
def _ensure_dirs(root: Path | str) -> Path:
|
|
base = relay_root(root)
|
|
for sub in (OUTBOX_DIR, CLAIMED_DIR, REPLIES_DIR):
|
|
(base / sub).mkdir(parents=True, exist_ok=True)
|
|
return base
|
|
|
|
|
|
def _atomic_write_json(target: Path, payload: Any, *, prefix: str, sort_keys: bool = False) -> None:
|
|
"""Write ``payload`` to ``target`` via tempfile + os.replace (readers never see
|
|
a partial file). The tempfile is removed if the write fails."""
|
|
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix=prefix, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=False, sort_keys=sort_keys)
|
|
os.replace(tmp, target)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
# ── remote roster ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _normalize_roster_row(row: Any) -> Optional[dict]:
|
|
"""Validated, minimal roster row or None.
|
|
|
|
Rows come from the Desktop over RPC — treat as untrusted input. A row names
|
|
an agent on another connection: profile, taggable handle, owning connection
|
|
id/label, and optional title/description for the protocol section.
|
|
"""
|
|
if not isinstance(row, dict):
|
|
return None
|
|
profile = str(row.get("profile") or "").strip()
|
|
handle = str(row.get("handle") or "").strip().lstrip("@")
|
|
connection_id = str(row.get("connection_id") or "").strip()
|
|
if not profile or not connection_id:
|
|
return None
|
|
if not handle:
|
|
handle = "hermes" if profile == "default" else profile
|
|
if (
|
|
not _HANDLE_RE.match(handle)
|
|
or not _HANDLE_RE.match(profile)
|
|
or not _HANDLE_RE.match(connection_id)
|
|
):
|
|
return None
|
|
out = {
|
|
"profile": profile,
|
|
"handle": handle,
|
|
"connection_id": connection_id,
|
|
"connection_label": str(row.get("connection_label") or "").strip()[:80],
|
|
"title": str(row.get("title") or "").strip()[:120],
|
|
"description": " ".join(str(row.get("description") or "").split())[:160],
|
|
}
|
|
# Optional liveness flag, kept only when a real bool so absent stays
|
|
# distinguishable from false: absent == unknown == fail-open on enqueue.
|
|
if isinstance(row.get("online"), bool):
|
|
out["online"] = row["online"]
|
|
return out
|
|
|
|
|
|
def write_remote_roster(root: Path | str, rows: Any) -> int:
|
|
"""Atomically persist the Desktop-pushed remote roster. Returns count."""
|
|
base = _ensure_dirs(root)
|
|
by_key: dict[tuple[str, str], dict] = {}
|
|
for row in rows if isinstance(rows, list) else []:
|
|
norm = _normalize_roster_row(row)
|
|
if norm:
|
|
by_key.setdefault((norm["connection_id"], norm["profile"]), norm)
|
|
cleaned = [by_key[k] for k in sorted(by_key)]
|
|
payload = {"updated_at": int(time.time()), "agents": cleaned}
|
|
_atomic_write_json(base / ROSTER_FILE, payload, prefix=".roster-", sort_keys=True)
|
|
return len(cleaned)
|
|
|
|
|
|
def read_remote_roster(root: Path | str) -> list[dict]:
|
|
"""The current remote roster (possibly empty). Never raises."""
|
|
try:
|
|
raw = (relay_root(root) / ROSTER_FILE).read_text(encoding="utf-8")
|
|
data = json.loads(raw)
|
|
agents = data.get("agents") if isinstance(data, dict) else None
|
|
if not isinstance(agents, list):
|
|
return []
|
|
return [r for r in (_normalize_roster_row(a) for a in agents) if r]
|
|
except FileNotFoundError:
|
|
return []
|
|
except Exception:
|
|
logger.debug("bot_relay roster read failed", exc_info=True)
|
|
return []
|
|
|
|
|
|
def resolve_remote_target(raw_target: str, roster: list[dict]) -> Any:
|
|
"""Resolve ``raw_target`` against the remote roster.
|
|
|
|
Accepted forms:
|
|
- bare handle/profile (``moxie``) — must be unique across connections;
|
|
- ``<handle>@<connection-id>`` / ``<profile>@<connection-id>`` — exact.
|
|
|
|
Returns the matched row, the string ``"ambiguous"`` when a bare form
|
|
matches agents on several connections, or None for no match.
|
|
"""
|
|
want = str(raw_target or "").strip().lstrip("@")
|
|
if not want:
|
|
return None
|
|
conn: Optional[str] = None
|
|
if "@" in want:
|
|
want, _, conn = want.partition("@")
|
|
want = want.strip()
|
|
conn = conn.strip()
|
|
if not want or not conn:
|
|
return None
|
|
matches = [
|
|
row
|
|
for row in roster
|
|
if want.lower() in (row["handle"].lower(), row["profile"].lower())
|
|
and (not conn or row["connection_id"].lower() == conn.lower())
|
|
]
|
|
if not matches:
|
|
return None
|
|
return matches[0] if len(matches) == 1 else "ambiguous"
|
|
|
|
|
|
def remote_target_forms(roster: list[dict]) -> list[str]:
|
|
"""Human/agent-facing target strings: bare handle when unique across
|
|
connections, else ``handle@connection`` (mirrors ``resolve_remote_target``)."""
|
|
handles = [row["handle"].lower() for row in roster]
|
|
return [
|
|
f"{row['handle']}@{row['connection_id']}" if handles.count(h) > 1 else row["handle"]
|
|
for row, h in zip(roster, handles)
|
|
]
|
|
|
|
|
|
# ── outbox / replies ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _envelope_ttl_seconds() -> int:
|
|
"""Configured drain TTL (``bot_mode.envelope_ttl_seconds``), read per-drain
|
|
(tools/ must not import CLI config at import time); falls back to
|
|
``DEFAULT_ENVELOPE_TTL_SECONDS``. ``0`` (or negative) disables expiry."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
|
|
cfg = load_config_readonly() or {}
|
|
val = (cfg.get("bot_mode") or {}).get("envelope_ttl_seconds")
|
|
if val is not None:
|
|
return int(val)
|
|
except Exception:
|
|
logger.debug("bot_relay TTL config read failed", exc_info=True)
|
|
return DEFAULT_ENVELOPE_TTL_SECONDS
|
|
|
|
|
|
def _target_liveness(root: Path | str, target: dict) -> Optional[bool]:
|
|
"""Tri-state liveness for ``target``: True / False / None (unknown).
|
|
|
|
'Definitively offline' = explicit ``online: false`` on the row, or the
|
|
target ABSENT from a *fresh* roster (the Desktop re-pushes the whole roster
|
|
on connection-state changes). A missing, unreadable, empty or stale roster
|
|
proves nothing → None, and callers fail open. Never raises.
|
|
"""
|
|
try:
|
|
roster_path = relay_root(root) / ROSTER_FILE
|
|
try:
|
|
age = time.time() - roster_path.stat().st_mtime
|
|
except OSError:
|
|
return None # no roster ever synced — unknown
|
|
if age > ROSTER_FRESH_SECONDS:
|
|
return None # stale view — unknown
|
|
roster = read_remote_roster(root)
|
|
if not roster:
|
|
return None # empty/corrupt roster — treat as unknown, fail open
|
|
key = (str(target.get("connection_id") or ""), str(target.get("profile") or ""))
|
|
for row in roster:
|
|
if (row["connection_id"], row["profile"]) == key:
|
|
online = row.get("online")
|
|
if online is False:
|
|
return False
|
|
return True if online is True else None
|
|
return False # fresh roster no longer lists the target — offline
|
|
except Exception:
|
|
logger.debug("bot_relay liveness check failed", exc_info=True)
|
|
return None
|
|
|
|
|
|
def enqueue_envelope(
|
|
root: Path | str,
|
|
*,
|
|
target: dict,
|
|
message: str,
|
|
sender_profile: str,
|
|
sender_handle: str,
|
|
) -> dict:
|
|
"""Queue a cross-connection DM for the Desktop relay. Returns envelope.
|
|
|
|
Raises ``EnvelopeRefusedError`` ('runtime_offline') without writing when the
|
|
target is definitively offline; unknown liveness enqueues (fail-open).
|
|
"""
|
|
if _target_liveness(root, target) is False:
|
|
label = (
|
|
f"@{target.get('handle') or target.get('profile') or '?'} on "
|
|
f"{target.get('connection_label') or target.get('connection_id') or '?'}"
|
|
)
|
|
raise EnvelopeRefusedError(
|
|
"runtime_offline",
|
|
f"{label} is offline right now — the message was NOT queued. "
|
|
"Try again once that machine reconnects to the Desktop.",
|
|
)
|
|
base = _ensure_dirs(root)
|
|
envelope = {
|
|
"id": uuid.uuid4().hex,
|
|
"created_at": int(time.time()),
|
|
"from_profile": sender_profile,
|
|
"from_handle": sender_handle,
|
|
"target_connection": target["connection_id"],
|
|
"target_profile": target["profile"],
|
|
"target_handle": target["handle"],
|
|
"message": message,
|
|
}
|
|
_atomic_write_json(base / OUTBOX_DIR / f"{envelope['id']}.json", envelope, prefix=".env-")
|
|
return envelope
|
|
|
|
|
|
def _expire_if_stale(root: Path | str, path: Path, ttl: float, now: float) -> bool:
|
|
"""True when the outbox envelope at ``path`` is older than ``ttl``; writes the
|
|
'queued_expired' error reply so the sender's waiter resolves (best effort —
|
|
an invalid id still counts as expired). Unreadable envelopes are left for
|
|
the claim attempt to deal with."""
|
|
try:
|
|
env = json.loads(path.read_text(encoding="utf-8"))
|
|
created = float(env.get("created_at") or path.stat().st_mtime)
|
|
except (OSError, ValueError):
|
|
return False
|
|
if now - created <= ttl:
|
|
return False
|
|
handle = str(env.get("target_handle") or "?")
|
|
conn = str(env.get("target_connection") or "?")
|
|
with contextlib.suppress(OSError, ValueError):
|
|
write_reply(
|
|
root,
|
|
str(env.get("id") or ""),
|
|
error=(
|
|
f"queued message to @{handle} on {conn} expired after "
|
|
f"{ttl}s waiting for the Desktop to drain it — it was "
|
|
"NOT delivered. Resend once the Desktop reconnects."
|
|
),
|
|
reason="queued_expired",
|
|
)
|
|
return True
|
|
|
|
|
|
def claim_pending_envelopes(root: Path | str) -> list[dict]:
|
|
"""Drain the outbox (rename → claimed/, so a second drain can't double-
|
|
deliver). Sweeps stale claimed/reply artifacts opportunistically.
|
|
|
|
Envelopes older than ``bot_mode.envelope_ttl_seconds`` are NOT delivered:
|
|
each gets a 'queued_expired' error reply (so the sender's waiter resolves)
|
|
and its outbox file is removed.
|
|
"""
|
|
base = _ensure_dirs(root)
|
|
_sweep_stale(base)
|
|
ttl = _envelope_ttl_seconds()
|
|
now = time.time()
|
|
out: list[dict] = []
|
|
for path in sorted((base / OUTBOX_DIR).glob("*.json")):
|
|
if ttl > 0 and _expire_if_stale(root, path, ttl, now):
|
|
with contextlib.suppress(OSError):
|
|
path.unlink()
|
|
continue
|
|
claimed = base / CLAIMED_DIR / path.name
|
|
try:
|
|
os.replace(path, claimed) # atomic claim
|
|
out.append(json.loads(claimed.read_text(encoding="utf-8")))
|
|
except (OSError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def write_reply(
|
|
root: Path | str, envelope_id: str, *, reply: str = "", error: str = "", reason: str = ""
|
|
) -> Path:
|
|
"""Persist the relayed reply (or delivery error) for the waiter.
|
|
|
|
``reason`` is an optional typed failure code (``tools.bot_failure_reasons``);
|
|
when omitted and ``error`` is non-empty it is classified from the text. The
|
|
waiter only surfaces the human ``error`` (plus the code as a tag).
|
|
"""
|
|
base = _ensure_dirs(root)
|
|
safe = str(envelope_id or "").strip()
|
|
if not re.match(r"^[0-9a-f]{32}$", safe):
|
|
raise ValueError(f"invalid envelope id: {envelope_id!r}")
|
|
err = str(error or "")
|
|
code = str(reason or "")
|
|
if not code and err:
|
|
from tools.bot_failure_reasons import classify_agent_error
|
|
|
|
code = classify_agent_error(err)
|
|
path = base / REPLIES_DIR / f"{safe}.json"
|
|
payload = {"id": safe, "at": int(time.time()), "reply": str(reply or ""), "error": err, "reason": code}
|
|
_atomic_write_json(path, payload, prefix=".rep-")
|
|
return path
|
|
|
|
|
|
def unlink_files_older_than(directory: Path, pattern: str, cutoff: float) -> int:
|
|
"""Remove regular files under ``directory`` matching ``pattern`` with mtime
|
|
before ``cutoff``; returns the count. Never raises (missing dir → 0)."""
|
|
removed = 0
|
|
try:
|
|
for path in directory.glob(pattern):
|
|
try:
|
|
if path.is_file() and path.stat().st_mtime < cutoff:
|
|
path.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
return removed
|
|
|
|
|
|
def _sweep_stale(base: Path, *, now: float | None = None) -> int:
|
|
cutoff = (time.time() if now is None else now) - STALE_AFTER_SECONDS
|
|
return sum(
|
|
unlink_files_older_than(base / sub, "*.json", cutoff)
|
|
for sub in (CLAIMED_DIR, REPLIES_DIR, OUTBOX_DIR)
|
|
)
|
|
|
|
|
|
def cleanup_bot_relay_artifacts(max_age_hours: float | None = None) -> int:
|
|
"""Sweep stale relay artifacts (envelopes/replies hold DM plaintext).
|
|
|
|
``_sweep_stale`` otherwise runs only on Desktop drains — if the Desktop never
|
|
reconnects, plaintext would sit on disk forever. Same contract as the
|
|
``cleanup_*_cache`` helpers (hourly housekeeping). ``max_age_hours`` is
|
|
accepted for signature compatibility; ``STALE_AFTER_SECONDS`` governs.
|
|
"""
|
|
del max_age_hours
|
|
try:
|
|
base = relay_root(_hermes_root(Path(_default_home())))
|
|
if not base.is_dir():
|
|
return 0
|
|
return _sweep_stale(base)
|
|
except Exception:
|
|
logger.debug("bot_relay artifact sweep failed", exc_info=True)
|
|
return 0
|
|
|
|
|
|
# ── waiter (runs on the sender gateway via terminal background process) ─────
|
|
|
|
|
|
def waiter_command(root: Path | str, envelope: dict) -> str:
|
|
"""Shell command that blocks until the reply file appears, then prints it.
|
|
|
|
Spawned via ``terminal_tool(background=True, notify_on_complete=True)`` so
|
|
its stdout — the reply — arrives as the same completion notification local
|
|
DMs use. Stdlib-only; runs under the sender gateway's interpreter.
|
|
"""
|
|
reply_path = str(relay_root(root) / REPLIES_DIR / f"{envelope['id']}.json")
|
|
label = (
|
|
f"@{envelope.get('target_handle', '')} "
|
|
f"on {envelope.get('target_connection', '')}"
|
|
)
|
|
# !r keeps roster fields from breaking out of the generated python -c source.
|
|
# The r-prefix keeps Windows paths viable: the Windows execution layer folds
|
|
# repr's "\\" back to "\", turning "\U" into an invalid unicode escape; a
|
|
# raw literal parses the folded backslash literally. No-op on POSIX, and \'
|
|
# still cannot terminate a raw literal, so the injection defense holds.
|
|
code = (
|
|
"import json,os,sys,time\n"
|
|
f"p = r{reply_path!r}\n"
|
|
f"label = r{label!r}\n"
|
|
f"deadline = time.time() + {REPLY_WAIT_SECONDS}\n"
|
|
"while time.time() < deadline:\n"
|
|
" if os.path.exists(p):\n"
|
|
" d = json.load(open(p, encoding='utf-8'))\n"
|
|
" if d.get('error'):\n"
|
|
# Typed reason code rides ahead of the free text so the sender can
|
|
# branch on it without parsing provider prose.
|
|
" code = str(d.get('reason') or '').strip()\n"
|
|
" tag = ' [reason: ' + code + ']' if code else ''\n"
|
|
" print('Delivery to ' + label + ' failed' + tag + ': ' + d['error'])\n"
|
|
" sys.exit(1)\n"
|
|
" print('Reply from ' + label + ':')\n"
|
|
" print(d.get('reply') or '(empty reply)')\n"
|
|
" sys.exit(0)\n"
|
|
# 250ms cadence: stat is cheap and a longer sleep is pure dead air.
|
|
" time.sleep(0.25)\n"
|
|
f"print('No reply from ' + label + ' within {REPLY_WAIT_SECONDS}s. The message may "
|
|
"still be delivered when the Desktop reconnects; do not resend blindly.')\n"
|
|
"sys.exit(1)\n"
|
|
)
|
|
return f"{shlex.quote(sys.executable or 'python3')} -c {shlex.quote(code)}"
|
|
|
|
|
|
# ── delivery command (used by the deliver RPC on the TARGET gateway) ────────
|
|
|
|
|
|
def _hermes_cli() -> str:
|
|
"""Resolve the hermes CLI beside this gateway's own interpreter.
|
|
|
|
Service contexts (systemd, desktop launchers, non-login SSH) lack PATH, so a
|
|
bare "hermes" died with ENOENT; the venv sibling wins, then ``shutil.which``
|
|
(honors whatever PATH exists), then the bare name.
|
|
"""
|
|
exe = Path(sys.executable or "")
|
|
sibling = exe.parent / ("hermes.exe" if sys.platform == "win32" else "hermes")
|
|
if sibling.is_file():
|
|
return str(sibling)
|
|
return shutil.which("hermes") or "hermes"
|
|
|
|
|
|
def local_delivery_command(profile: str, query_file: str) -> list[str]:
|
|
"""argv that delivers a DM into ``profile``'s Bot Chat on THIS gateway."""
|
|
return [_hermes_cli(), "-p", profile, *BOT_CHAT_TURN_ARGS, "--query-file", query_file]
|
|
|
|
|
|
# ── per-profile turn lock ────────────────────────────────────────────────────
|
|
#
|
|
# Two deliveries into the SAME profile must never run Bot Chat turns
|
|
# concurrently. Deliveries are separate ``hermes`` subprocesses, so the lock is
|
|
# a per-profile lockfile under ``<root>/bot_relay/locks/`` held with
|
|
# ``fcntl.flock`` for exactly the turn window; the kernel releases it on fd
|
|
# close (including process death), so a crashed turn can never wedge the
|
|
# profile. Waiters are bounded by ``bot_mode.turn_wait_seconds`` and then fail
|
|
# with a structured 'target_busy' refusal.
|
|
|
|
|
|
class TurnBusyError(RuntimeError):
|
|
"""A delivery turn is already running for the target profile.
|
|
|
|
``waited_seconds``: roughly how long the caller queued before giving up.
|
|
"""
|
|
|
|
reason = "target_busy"
|
|
|
|
def __init__(self, profile: str, waited_seconds: float):
|
|
self.profile = profile
|
|
self.waited_seconds = waited_seconds
|
|
super().__init__(
|
|
f"target_busy: another delivery turn is already running for "
|
|
f"profile '{profile}' — queued behind it for ~{int(round(waited_seconds))}s "
|
|
"without it finishing. The message was NOT delivered; retry shortly."
|
|
)
|
|
|
|
|
|
def turn_wait_seconds() -> float:
|
|
"""Wait budget for a queued delivery turn (config, lazily read)."""
|
|
try:
|
|
from hermes_cli.config import cfg_get, load_config
|
|
|
|
val = cfg_get(load_config(), "bot_mode", "turn_wait_seconds", default=None)
|
|
if val is not None:
|
|
return max(0.0, float(val))
|
|
except Exception:
|
|
logger.debug("bot_mode.turn_wait_seconds read failed", exc_info=True)
|
|
return float(TURN_WAIT_SECONDS_FALLBACK)
|
|
|
|
|
|
def turn_lock_path(root: Path | str, profile: str) -> Path:
|
|
"""Per-profile lockfile path (short — safe on macOS temp roots)."""
|
|
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", str(profile or ""))[:64] or "_"
|
|
return relay_root(root) / LOCKS_DIR / f"{safe}.lock"
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def acquire_turn_lock(
|
|
root: Path | str, profile: str, timeout_seconds: float | None = None
|
|
) -> Iterator[Path]:
|
|
"""Hold ``profile``'s cross-process turn lock for the ``with`` body.
|
|
|
|
Non-blocking flock probe + short-sleep retry up to the budget
|
|
(``bot_mode.turn_wait_seconds`` unless ``timeout_seconds`` is given). No
|
|
ordering among waiters, but every waiter is bounded — no deadlock. Raises
|
|
:class:`TurnBusyError` when the budget is exhausted. Without ``fcntl``
|
|
(Windows) the lock is a no-op — those installs never had this race path
|
|
in production.
|
|
"""
|
|
try:
|
|
import fcntl
|
|
except ImportError: # pragma: no cover — Windows
|
|
logger.debug("bot turn lock disabled: fcntl unavailable on this platform")
|
|
yield turn_lock_path(root, profile)
|
|
return
|
|
|
|
budget = turn_wait_seconds() if timeout_seconds is None else max(0.0, float(timeout_seconds))
|
|
path = turn_lock_path(root, profile)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
try:
|
|
start = time.monotonic()
|
|
deadline = start + budget
|
|
while True:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
break
|
|
except OSError:
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
raise TurnBusyError(profile, now - start)
|
|
time.sleep(min(0.1, max(0.005, deadline - now)))
|
|
try:
|
|
yield path
|
|
finally:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
except OSError: # pragma: no cover — kernel releases on close anyway
|
|
pass
|
|
finally:
|
|
os.close(fd)
|