Files
hermes-agent/tools/process_registry_results.py
Sora-bluesky 9085ef967c fix(profiles): sweep the remaining pre-write mkdirs under the deleted-profile guard
A long-lived serve process keeps a deleted profile as the context home of threads
that outlive the delete. A bare `mkdir(parents=True)` right before an atomic write
brings `profiles/<name>/` back after `hermes profile delete` has written the
tombstone and removed the tree.

The writers in `utils` and the seven callers named in #112592 are guarded by the
preceding commits; this one applies the same `mkdir_under_hermes_home` idiom to the
other pre-write directory creations found by the same mechanical rule (auth,
personality, plugin catalog, skills sync, tool discovery cache, platform adapters,
memory plugins, local runtime supervisor, process identity, breadcrumbs). The two
sites that pass `mode=` keep their mkdir behind `assert_named_profile_home_live`.
The guard is a no-op unless the target has a provable `profiles/<name>` ancestor.

Salvaged from #112596 (30-file sweep) on top of #112594 / #112601; the overlapping
files were resolved to the already-landed versions.
2026-09-16 00:32:15 -07: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"))
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