Files
hermes-agent/cron/delivery_queue.py
teknium1 576accd92b refactor(sqlite): one open_db/transaction layer for every small store; plugin DBs use the WAL fallback
Twelve modules each carried their own sqlite3.connect + PRAGMA + `with conn:`
stack. The #69567 fd-leak fix (a `with conn:` commits but never closes, so each
call leaked a connection and its WAL/SHM fds until GC) was pasted as code plus
docstring into six of them and hosted_room_policy_checkpoint never received
it; plugins/plugin_storage.plugin_db was the only production caller issuing a
raw `PRAGMA journal_mode=WAL`, bypassing the network-FS fallback, the
WAL-reset-bug gate and the never-live-downgrade invariant that
hermes_state_wal.apply_wal_with_fallback carries.

hermes_cli/sqlite_util.py (already home to add_column_if_missing/write_txn,
imported by cron, gateway and hermes_cli alike) gains `open_db(path, *,
db_label, busy_timeout_ms, wal, foreign_keys, synchronous_full, row_factory,
check_same_thread, wal_lock_retries, initialize)` and `transaction(conn,
immediate=)`; cron/ledger.py is deleted and hosted_rooms_common's
open_sqlite/connect/transaction become 1-3 line forwarders. Migrated:
agent/verification_evidence, cron/{executions,incidents,notepad,
delivery_queue}, gateway/{delivery_ledger,hosted_room_policy_checkpoint,
hosted_rooms_common (-> hosted_rooms, hosted_room_driver)}, hermes_cli/
projects_db, tools/async_delegation, plugins/plugin_storage.

Behavior changes (each module keeps its effective PRAGMA set otherwise):
- hosted_room_policy_checkpoint: connection now closed after every use and
  on init failure (was leaked per call), busy_timeout PRAGMA set explicitly.
- projects_db: gains busy_timeout=5000 (was the sqlite3 default 5 s connect
  timeout with no PRAGMA); explicit and observable.
- delivery_ledger / async_delegation: busy_timeout PRAGMA now mirrors the
  10 s connect timeout they already had.
- plugin_storage.plugin_db: WAL through apply_wal_with_fallback (DELETE on
  network filesystems / WAL-reset-vulnerable builds instead of raw WAL);
  busy_timeout=5000.
- cron/incidents._redact_error: redact_sensitive_text(force=True) — the
  error text is persisted to disk.
- delivery_ledger's private duplicate-column guard and the unguarded
  `ALTER TABLE ADD COLUMN` sites (shared_metrics, api_server_run_idempotency,
  holographic store, kanban model_override) go through add_column_if_missing.
- hermes_state.py::_scrub_surrogates: dead byte-copy of
  hermes_state_messages._scrub_surrogates (0 callers) deleted.
2026-09-13 05:08:29 -07:00

379 lines
13 KiB
Python

"""Profile-local durable handoff for cron delivery through live gateway adapters.
A restart-safe cron worker executes outside the gateway cgroup. It cannot own
relay/E2EE adapter objects, so it queues the final send here. A gateway claims
each row at most once. If that gateway dies after claiming, the outcome is
marked unknown and never retried: losing a delivery is safer than duplicating a
possibly-completed send.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Iterator, Optional
from agent.redact import redact_sensitive_text
from cron.executions import _owner_is_live, _process_start_time
from hermes_cli.sqlite_util import add_column_if_missing, open_db, transaction
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
logger = logging.getLogger(__name__)
DELIVERY_DB: Optional[Path] = None
_PROCESS_ID = uuid.uuid4().hex
_lock = threading.RLock()
_ACTIVE_DELIVERIES: set[str] = set()
_TERMINAL = ("delivered", "failed", "unknown")
MAX_TERMINAL_DELIVERIES = 1000
DEFAULT_DELIVERY_WAIT_TIMEOUT_SECONDS = 300.0
def _prune_terminal_unlocked(conn: sqlite3.Connection) -> None:
"""Redact terminal payloads and retain only bounded outcome metadata."""
conn.execute(
"""UPDATE deliveries SET job_json='{}', content=''
WHERE status IN ('delivered','failed','unknown')
AND (job_json != '{}' OR content != '')"""
)
keep = max(0, int(MAX_TERMINAL_DELIVERIES))
terminal_count = int(
conn.execute(
"SELECT COUNT(*) FROM deliveries "
"WHERE status IN ('delivered','failed','unknown')"
).fetchone()[0]
)
excess = terminal_count - keep
if excess > 0:
conn.execute(
"""INSERT OR IGNORE INTO delivery_tombstones
(execution_id, terminal_status, finished_at)
SELECT execution_id, status, finished_at FROM deliveries
WHERE status IN ('delivered','failed','unknown')
ORDER BY finished_at, created_at, execution_id
LIMIT ?""",
(excess,),
)
conn.execute(
"""DELETE FROM deliveries WHERE execution_id IN (
SELECT execution_id FROM deliveries
WHERE status IN ('delivered','failed','unknown')
ORDER BY finished_at, created_at, execution_id
LIMIT ?
)""",
(excess,),
)
def _path() -> Path:
return DELIVERY_DB or (get_hermes_home().resolve() / "cron" / "deliveries.db")
def _initialize_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""CREATE TABLE IF NOT EXISTS deliveries (
execution_id TEXT PRIMARY KEY,
job_json TEXT NOT NULL,
content TEXT NOT NULL,
for_failure INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK(status IN
('pending','delivering','delivered','failed','unknown')),
owner_process_id TEXT,
owner_pid INTEGER,
owner_started_at INTEGER,
created_at TEXT NOT NULL,
finished_at TEXT,
error TEXT
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS delivery_tombstones (
execution_id TEXT PRIMARY KEY,
terminal_status TEXT NOT NULL CHECK(terminal_status IN
('delivered','failed','unknown')),
finished_at TEXT
)"""
)
add_column_if_missing(
conn, "deliveries", "for_failure",
"for_failure INTEGER NOT NULL DEFAULT 0",
)
def _connect() -> sqlite3.Connection:
path = _path()
conn = open_db(path, db_label="cron/deliveries.db", synchronous_full=True, initialize=_initialize_schema)
try:
path.chmod(0o600)
except OSError:
pass
return conn
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
# Pruning is done explicitly by the paths that create terminal
# rows (_finish / recover_abandoned / _terminalize_wait_timeout);
# read-only polls must not pay for a full-table UPDATE + COUNT.
with _lock, transaction(_connect()) as conn:
yield conn
def enqueue(
execution_id: str,
job: dict,
content: str,
*,
for_failure: bool = False,
) -> dict:
"""Persist one idempotent delivery request before the worker waits."""
with _transaction() as conn:
# Serialize the tombstone check and insert with retention in other
# processes, which can move a terminal delivery into the tombstone table.
conn.execute("BEGIN IMMEDIATE")
tombstone = conn.execute(
"SELECT terminal_status, finished_at FROM delivery_tombstones "
"WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if tombstone is not None:
return {
"execution_id": str(execution_id),
"status": tombstone["terminal_status"],
"finished_at": tombstone["finished_at"],
}
conn.execute(
"""INSERT OR IGNORE INTO deliveries
(execution_id, job_json, content, for_failure, status, created_at)
VALUES (?, ?, ?, ?, 'pending', ?)""",
(
str(execution_id),
json.dumps(job, ensure_ascii=False, sort_keys=True),
str(content),
int(bool(for_failure)),
_hermes_now().isoformat(),
),
)
row = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (str(execution_id),)
).fetchone()
return dict(row)
def get_status(execution_id: str) -> Optional[dict]:
with _transaction() as conn:
row = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (str(execution_id),)
).fetchone()
if row is not None:
return dict(row)
tombstone = conn.execute(
"SELECT execution_id, terminal_status, finished_at "
"FROM delivery_tombstones WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if tombstone is None:
return None
return {
"execution_id": tombstone["execution_id"],
"status": tombstone["terminal_status"],
"finished_at": tombstone["finished_at"],
"error": None,
}
def claim_next() -> Optional[dict]:
"""Atomically claim one pending send before touching the transport."""
pid = os.getpid()
started = _process_start_time(pid)
with _transaction() as conn:
row = conn.execute(
"SELECT execution_id FROM deliveries WHERE status='pending' "
"ORDER BY created_at, execution_id LIMIT 1"
).fetchone()
if row is None:
return None
cur = conn.execute(
"""UPDATE deliveries SET status='delivering', owner_process_id=?,
owner_pid=?, owner_started_at=?
WHERE execution_id=? AND status='pending'""",
(_PROCESS_ID, pid, started, row["execution_id"]),
)
if cur.rowcount != 1:
return None
claimed = conn.execute(
"SELECT * FROM deliveries WHERE execution_id=?", (row["execution_id"],)
).fetchone()
_ACTIVE_DELIVERIES.add(row["execution_id"])
result = dict(claimed)
result["job"] = json.loads(result.pop("job_json"))
return result
def _finish(execution_id: str, *, error: Optional[str]) -> bool:
status = "failed" if error else "delivered"
safe_error = (
redact_sensitive_text(str(error), force=True, redact_url_credentials=True)
if error
else None
)
with _transaction() as conn:
cur = conn.execute(
"""UPDATE deliveries SET status=?, finished_at=?, error=?
WHERE execution_id=? AND status='delivering'
AND owner_process_id=? AND owner_pid=?""",
(
status,
_hermes_now().isoformat(),
safe_error,
execution_id,
_PROCESS_ID,
os.getpid(),
),
)
_prune_terminal_unlocked(conn)
return cur.rowcount == 1
def recover_abandoned() -> int:
"""Fence dead delivery owners as unknown; never replay uncertain sends."""
changed = 0
with _transaction() as conn:
rows = conn.execute(
"SELECT execution_id, owner_process_id, owner_pid, owner_started_at "
"FROM deliveries WHERE status='delivering'"
).fetchall()
for row in rows:
same_process = row["owner_process_id"] == _PROCESS_ID
if same_process:
with _lock:
if row["execution_id"] in _ACTIVE_DELIVERIES:
continue
elif _owner_is_live(int(row["owner_pid"]), row["owner_started_at"]):
continue
error = (
"Gateway finished delivery but could not persist its outcome; "
"send was not retried."
if same_process
else "Gateway exited during delivery; send outcome is unknown and was not retried."
)
cur = conn.execute(
"""UPDATE deliveries SET status='unknown', finished_at=?, error=?
WHERE execution_id=? AND status='delivering'""",
(
_hermes_now().isoformat(),
error,
row["execution_id"],
),
)
changed += cur.rowcount
_prune_terminal_unlocked(conn)
return changed
def drain(
send: Callable[[dict, str, bool], Optional[str]], *, limit: int = 20
) -> int:
"""Deliver pending rows through *send*, terminalizing every claimed row."""
recover_abandoned()
processed = 0
for _ in range(max(0, limit)):
row = claim_next()
if row is None:
break
with _lock:
_ACTIVE_DELIVERIES.add(row["execution_id"])
try:
try:
error = send(
row["job"], row["content"], bool(row["for_failure"])
)
except BaseException as exc:
error = f"{type(exc).__name__}: {exc}"
_finish(row["execution_id"], error=error)
finally:
with _lock:
_ACTIVE_DELIVERIES.discard(row["execution_id"])
processed += 1
return processed
def _terminalize_wait_timeout(execution_id: str) -> str:
"""Fence a delivery whose worker can no longer wait for confirmation.
A row still ``pending`` was provably never attempted, so it is left queued
for whichever gateway comes up next (a restart that includes an update can
easily exceed the worker's wait budget). That is a deferral, not a
failure: report success so the job is not recorded ``delivery_failed`` for
a message the drain will still send. Only a row caught mid-send is
uncertain and gets fenced ``unknown``.
"""
now = _hermes_now().isoformat()
uncertain_error = (
"timed out while gateway delivery was in progress; outcome is unknown and "
"was not retried"
)
with _transaction() as conn:
row = conn.execute(
"SELECT status FROM deliveries WHERE execution_id=?",
(str(execution_id),),
).fetchone()
if row is not None and row["status"] == "pending":
logger.warning(
"Cron delivery %s: no live gateway within the wait budget; "
"left queued for the next gateway",
execution_id,
)
return ""
conn.execute(
"""UPDATE deliveries SET status='unknown', finished_at=?, error=?
WHERE execution_id=? AND status='delivering'""",
(now, uncertain_error, str(execution_id)),
)
row = conn.execute(
"SELECT status, error FROM deliveries WHERE execution_id=?",
(str(execution_id),),
).fetchone()
_prune_terminal_unlocked(conn)
if row is None:
return "timed out waiting for live gateway delivery"
if row["status"] == "delivered":
return ""
return str(row["error"] or f"delivery {row['status']}")
def enqueue_and_wait(
execution_id: str,
job: dict,
content: str,
*,
for_failure: bool = False,
timeout: Optional[float] = None,
) -> Optional[str]:
"""Queue delivery and wait for a gateway's terminal at-most-once outcome."""
queued = enqueue(execution_id, job, content, for_failure=for_failure)
if queued["status"] in _TERMINAL:
return None if queued["status"] == "delivered" else str(
queued.get("error") or f"delivery {queued['status']}"
)
wait_timeout = (
DEFAULT_DELIVERY_WAIT_TIMEOUT_SECONDS if timeout is None else max(0.0, timeout)
)
deadline = time.monotonic() + wait_timeout
while time.monotonic() < deadline:
row = get_status(execution_id)
if row and row["status"] in _TERMINAL:
return None if row["status"] == "delivered" else str(
row.get("error") or f"delivery {row['status']}"
)
time.sleep(1.0)
return _terminalize_wait_timeout(execution_id) or None