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.
119 lines
4.5 KiB
Python
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
|