Files
hermes-agent/tools/process_registry_results.py
ethernet b4a294fff9 Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
2026-09-17 13:52:05 -04:00

119 lines
4.5 KiB
Python

"""Bounded, profile-local receipts for completed terminal processes.
Receipts are read through process_manage, never replayed as notifications or
adopted as live PIDs. Each producer writes its own file so independent one-shot
parents cannot overwrite each other's results in the running-PID checkpoint.
"""
import json
import logging
import re
import sqlite3
import time
from hermes_constants import get_hermes_home
from utils import atomic_json_write
logger = logging.getLogger("tools.process_registry")
RESULT_RETENTION_SECONDS = 7 * 24 * 60 * 60
MAX_RETAINED_RESULTS = 64
_RESULT_FIELDS = (
"id", "command", "cwd", "task_id", "owner_task_id", "session_key",
"parent_session_id", "started_at", "exit_code", "completion_reason",
"termination_source", "notify_on_complete",
)
def _result_paths():
"""Prune by completion time, not start time (jobs can take days)."""
directory = get_hermes_home() / "logs" / "process-results"
cutoff = time.time() - RESULT_RETENTION_SECONDS
retained = []
for path in directory.glob("proc_*.json"):
try:
modified = path.stat().st_mtime
if modified < cutoff:
path.unlink(missing_ok=True)
else:
retained.append((modified, path))
except FileNotFoundError:
continue # Another producer pruned it.
retained.sort(key=lambda item: (item[0], item[1].name), reverse=True)
for _, path in retained[MAX_RETAINED_RESULTS:]:
path.unlink(missing_ok=True)
return [path for _, path in retained[:MAX_RETAINED_RESULTS]]
def save_completed_result(session) -> None:
from agent.redact import redact_sensitive_text, redact_terminal_output
from tools.process_registry import MAX_OUTPUT_CHARS
with session._lock:
record = {key: getattr(session, key) for key in _RESULT_FIELDS}
record["output"] = session.output_buffer[-MAX_OUTPUT_CHARS:]
# Live-output opt-out must not persist raw credentials in durable receipts.
record["output"] = redact_terminal_output(record["output"], record["command"], force=True)
record["command"] = redact_sensitive_text(record["command"], code_file=True, force=True)
directory = get_hermes_home() / "logs" / "process-results"
try:
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(directory)
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
atomic_json_write(directory / f"{session.id}.json", record, mode=0o600)
_result_paths()
except OSError:
# Preserve live delivery on disk failure, but never silently claim durability.
logger.warning("Could not retain completed process result %s", session.id, exc_info=True)
def _owns_result(owner: str, parent: str | None) -> bool:
if not parent:
return False
if owner == parent:
return True
from hermes_state import SessionDB
# Pure lineage read on the hot path of every retained-result load; a writable open here
# was one more writer handle per call inside the gateway (#100896).
db = SessionDB(read_only=True)
try:
return db.get_compression_tip(parent) == owner
finally:
db.close()
def load_completed_results(prefix: str = "") -> dict:
"""Restore read-only snapshots; no process handles, watchers, or queue events."""
from tools.process_registry import ProcessSession
from gateway.session_context import get_session_env
owner = get_session_env("HERMES_SESSION_ID", "")
if not owner:
return {}
results = {}
try:
paths = _result_paths()
except OSError:
logger.warning("Could not read retained process results", exc_info=True)
return results
for path in paths:
if not path.stem.startswith(prefix):
continue
try:
record = json.loads(path.read_text(encoding="utf-8-sig"))
if record["id"] != path.stem or not re.fullmatch(r"proc_[\w]+", record["id"]):
continue
if not _owns_result(owner, record.get("parent_session_id")):
continue
session = ProcessSession(
**{key: record[key] for key in _RESULT_FIELDS},
exited=True, output_buffer=record["output"],
)
session._completion_event.set()
results[session.id] = session
except (OSError, ValueError, KeyError, TypeError, sqlite3.Error):
logger.debug("Skipping unreadable process result %s", path.name, exc_info=True)
return results