2026-09-04 11:25:07 +02:00
|
|
|
"""Write per-run metrics to compatibility or durable external storage.
|
2026-07-17 23:49:03 +02:00
|
|
|
|
2026-09-04 11:25:07 +02:00
|
|
|
Legacy grant-absent runs retain the kaizen-agentic ADR-004 repository layout.
|
|
|
|
|
Accepted grant runs use private external state and a projection descriptor so
|
|
|
|
|
metrics cannot dirty the validated checkout.
|
2026-07-17 23:49:03 +02:00
|
|
|
|
|
|
|
|
.kaizen/metrics/<agent>/
|
|
|
|
|
executions.jsonl # append-only
|
|
|
|
|
summary.json # regenerated on write
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-09-04 11:25:07 +02:00
|
|
|
import fcntl
|
|
|
|
|
import hashlib
|
2026-07-17 23:49:03 +02:00
|
|
|
import json
|
2026-09-04 11:25:07 +02:00
|
|
|
import os
|
|
|
|
|
import uuid
|
2026-07-17 23:49:03 +02:00
|
|
|
from dataclasses import asdict, dataclass, field
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class ExecutionRecord:
|
|
|
|
|
timestamp: str
|
|
|
|
|
agent: str
|
|
|
|
|
success: bool
|
|
|
|
|
execution_time_s: float = 0.0
|
|
|
|
|
session_id: str | None = None
|
|
|
|
|
quality_score: float | None = None
|
|
|
|
|
primary_metric: dict[str, Any] | None = None
|
|
|
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
# Helix / harness correlation (ADR-004 optional fields)
|
|
|
|
|
repo: str | None = None
|
|
|
|
|
tokens: int | None = None
|
|
|
|
|
committed: bool | None = None
|
|
|
|
|
head_after: str | None = None
|
|
|
|
|
reason: str | None = None
|
Rename package, CLI, and deploy artifacts to rein-aharness (HARNESS-WP-0002-T02)
agent_harness -> rein_aharness (package + all imports), CLI command
agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/
names, Makefile targets, deploy script env var/paths. In-repo identity
strings (hub event source, metrics harness field, default assignee,
argparse prog name, commit author identity) updated to match.
Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md,
docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001
(completed under the old name), and the SSH host alias
"forgejo-agent-harness" (external ~/.ssh/config entry, not owned here).
Verified: 47/47 tests pass, CLI runs correctly from a fresh venv,
`make image` builds and the resulting container runs correctly.
deploy/README.md gained an explicit rename cutover checklist for what
this session cannot safely do unattended -- moving the host-side
secrets dir and checkout on railiance01, and not deleting the old k8s
namespace until the new one is confirmed working. The actual live
cutover (running that checklist against the real Railiance deployment)
is not attempted here -- real production surgery on binky-control's
live automation, needs the operator present.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 14:22:18 +02:00
|
|
|
harness: str = "rein-aharness"
|
2026-07-17 23:49:03 +02:00
|
|
|
|
|
|
|
|
def to_json_line(self) -> str:
|
|
|
|
|
data = asdict(self)
|
|
|
|
|
# Drop Nones for a compact record; required fields always present.
|
|
|
|
|
compact = {k: v for k, v in data.items() if v is not None}
|
|
|
|
|
return json.dumps(compact, sort_keys=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def metrics_dir(project_root: Path, agent: str) -> Path:
|
|
|
|
|
return Path(project_root) / ".kaizen" / "metrics" / agent
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 11:25:07 +02:00
|
|
|
def external_metrics_dir(
|
|
|
|
|
project_root: Path | str,
|
|
|
|
|
agent: str,
|
|
|
|
|
*,
|
|
|
|
|
state_dir: Path | None = None,
|
|
|
|
|
) -> Path:
|
|
|
|
|
"""Return the private durable metrics directory for a granted run."""
|
|
|
|
|
root = Path(project_root).expanduser().resolve()
|
|
|
|
|
base = state_dir.expanduser().resolve() if state_dir else _state_dir()
|
|
|
|
|
repo_id = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:32]
|
|
|
|
|
agent_id = hashlib.sha256(str(agent).encode("utf-8")).hexdigest()[:32]
|
|
|
|
|
return base / "execution-metrics" / repo_id / agent_id
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 23:49:03 +02:00
|
|
|
def _utc_now() -> str:
|
|
|
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
|
|
|
|
|
"+00:00", "Z"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_executions(path: Path) -> list[dict[str, Any]]:
|
|
|
|
|
if not path.is_file():
|
|
|
|
|
return []
|
|
|
|
|
records: list[dict[str, Any]] = []
|
|
|
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
records.append(json.loads(line))
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
continue
|
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trend(values: list[float]) -> str:
|
|
|
|
|
if len(values) < 4:
|
|
|
|
|
return "stable"
|
|
|
|
|
mid = len(values) // 2
|
|
|
|
|
early = sum(values[:mid]) / max(1, mid)
|
|
|
|
|
late = sum(values[mid:]) / max(1, len(values) - mid)
|
|
|
|
|
if late - early > 0.05:
|
|
|
|
|
return "up"
|
|
|
|
|
if early - late > 0.05:
|
|
|
|
|
return "down"
|
|
|
|
|
return "stable"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def regenerate_summary(agent: str, executions: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
|
|
count = len(executions)
|
|
|
|
|
successes = [e for e in executions if e.get("success")]
|
|
|
|
|
success_rate = (len(successes) / count) if count else 0.0
|
|
|
|
|
times = [float(e["execution_time_s"]) for e in executions if "execution_time_s" in e]
|
|
|
|
|
qualities = [
|
|
|
|
|
float(e["quality_score"])
|
|
|
|
|
for e in executions
|
|
|
|
|
if isinstance(e.get("quality_score"), (int, float))
|
|
|
|
|
]
|
|
|
|
|
last_ts = executions[-1].get("timestamp") if executions else None
|
|
|
|
|
return {
|
|
|
|
|
"agent": agent,
|
|
|
|
|
"execution_count": count,
|
|
|
|
|
"success_rate": round(success_rate, 3),
|
|
|
|
|
"avg_quality_score": (
|
|
|
|
|
round(sum(qualities) / len(qualities), 3) if qualities else None
|
|
|
|
|
),
|
|
|
|
|
"avg_execution_time_s": (
|
|
|
|
|
round(sum(times) / len(times), 3) if times else None
|
|
|
|
|
),
|
|
|
|
|
"last_execution": last_ts,
|
|
|
|
|
"trend": {
|
|
|
|
|
"success_rate": _trend(
|
|
|
|
|
[1.0 if e.get("success") else 0.0 for e in executions]
|
|
|
|
|
),
|
|
|
|
|
"quality_score": _trend(qualities) if qualities else "stable",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def record_execution(
|
|
|
|
|
project_root: Path | str,
|
|
|
|
|
agent: str,
|
|
|
|
|
*,
|
|
|
|
|
success: bool,
|
|
|
|
|
execution_time_s: float = 0.0,
|
|
|
|
|
tokens: int | None = None,
|
|
|
|
|
committed: bool | None = None,
|
|
|
|
|
head_after: str | None = None,
|
|
|
|
|
reason: str | None = None,
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
|
|
|
|
session_id: str | None = None,
|
|
|
|
|
) -> Path:
|
|
|
|
|
"""Append one execution record and regenerate summary.json.
|
|
|
|
|
|
|
|
|
|
Returns the path to executions.jsonl. Never raises for missing parent
|
|
|
|
|
dirs (creates them). Callers that must not write should skip this.
|
|
|
|
|
"""
|
|
|
|
|
root = Path(project_root)
|
|
|
|
|
directory = metrics_dir(root, agent)
|
|
|
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-09-04 11:25:07 +02:00
|
|
|
record = _execution_record(
|
|
|
|
|
root,
|
|
|
|
|
agent,
|
2026-07-17 23:49:03 +02:00
|
|
|
success=success,
|
2026-09-04 11:25:07 +02:00
|
|
|
execution_time_s=execution_time_s,
|
2026-07-17 23:49:03 +02:00
|
|
|
tokens=tokens,
|
|
|
|
|
committed=committed,
|
|
|
|
|
head_after=head_after,
|
|
|
|
|
reason=reason,
|
2026-09-04 11:25:07 +02:00
|
|
|
metadata=metadata,
|
|
|
|
|
session_id=session_id,
|
2026-07-17 23:49:03 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
executions_path = directory / "executions.jsonl"
|
|
|
|
|
with executions_path.open("a", encoding="utf-8") as fh:
|
|
|
|
|
fh.write(record.to_json_line() + "\n")
|
|
|
|
|
|
|
|
|
|
all_records = _load_executions(executions_path)
|
|
|
|
|
summary = regenerate_summary(agent, all_records)
|
|
|
|
|
(directory / "summary.json").write_text(
|
|
|
|
|
json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
return executions_path
|
2026-09-04 11:25:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def record_external_execution(
|
|
|
|
|
project_root: Path | str,
|
|
|
|
|
agent: str,
|
|
|
|
|
*,
|
|
|
|
|
success: bool,
|
|
|
|
|
execution_time_s: float = 0.0,
|
|
|
|
|
tokens: int | None = None,
|
|
|
|
|
committed: bool | None = None,
|
|
|
|
|
head_after: str | None = None,
|
|
|
|
|
reason: str | None = None,
|
|
|
|
|
metadata: dict[str, Any] | None = None,
|
|
|
|
|
session_id: str | None = None,
|
|
|
|
|
state_dir: Path | None = None,
|
|
|
|
|
) -> Path:
|
|
|
|
|
"""Durably record granted-run metrics without dirtying the target checkout."""
|
|
|
|
|
root = Path(project_root).expanduser().resolve()
|
|
|
|
|
projection_target = _projection_target(agent)
|
|
|
|
|
directory = external_metrics_dir(root, agent, state_dir=state_dir)
|
|
|
|
|
_ensure_private_directory(directory)
|
|
|
|
|
lock_path = directory / ".lock"
|
|
|
|
|
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
|
|
|
try:
|
|
|
|
|
os.fchmod(lock_fd, 0o600)
|
|
|
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
|
|
|
record = _execution_record(
|
|
|
|
|
root,
|
|
|
|
|
agent,
|
|
|
|
|
success=success,
|
|
|
|
|
execution_time_s=execution_time_s,
|
|
|
|
|
tokens=tokens,
|
|
|
|
|
committed=committed,
|
|
|
|
|
head_after=head_after,
|
|
|
|
|
reason=reason,
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
)
|
|
|
|
|
executions_path = directory / "executions.jsonl"
|
|
|
|
|
existing = (
|
|
|
|
|
executions_path.read_text(encoding="utf-8")
|
|
|
|
|
if executions_path.exists()
|
|
|
|
|
else ""
|
|
|
|
|
)
|
|
|
|
|
if existing and not existing.endswith("\n"):
|
|
|
|
|
raise OSError("external metrics ledger has an incomplete record")
|
|
|
|
|
records = _load_external_executions(executions_path) if existing else []
|
|
|
|
|
already_recorded = session_id is not None and any(
|
|
|
|
|
item.get("session_id") == session_id for item in records
|
|
|
|
|
)
|
|
|
|
|
if not already_recorded:
|
|
|
|
|
record_line = record.to_json_line()
|
|
|
|
|
_atomic_write_text(executions_path, existing + record_line + "\n")
|
|
|
|
|
records.append(json.loads(record_line))
|
|
|
|
|
_atomic_write_text(
|
|
|
|
|
directory / "summary.json",
|
|
|
|
|
json.dumps(regenerate_summary(agent, records), indent=2, sort_keys=True)
|
|
|
|
|
+ "\n",
|
|
|
|
|
)
|
|
|
|
|
repo_id = directory.parent.name
|
|
|
|
|
_atomic_write_text(
|
|
|
|
|
directory / "projection.json",
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"agent": agent,
|
|
|
|
|
"repository_id": repo_id,
|
|
|
|
|
"repository_name": root.name,
|
|
|
|
|
"target_relative_directory": projection_target,
|
|
|
|
|
},
|
|
|
|
|
indent=2,
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
)
|
|
|
|
|
+ "\n",
|
|
|
|
|
)
|
|
|
|
|
_fsync_directory(directory)
|
|
|
|
|
return executions_path
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(lock_fd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _projection_target(agent: str) -> str:
|
|
|
|
|
"""Return a checkout-relative projection target for a safe agent identity."""
|
|
|
|
|
if (
|
|
|
|
|
not agent
|
|
|
|
|
or agent in {".", ".."}
|
|
|
|
|
or "/" in agent
|
|
|
|
|
or "\\" in agent
|
|
|
|
|
or "\x00" in agent
|
|
|
|
|
or len(agent) > 200
|
|
|
|
|
):
|
|
|
|
|
raise OSError("agent identity cannot form a safe metrics projection")
|
|
|
|
|
return f".kaizen/metrics/{agent}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _execution_record(
|
|
|
|
|
root: Path,
|
|
|
|
|
agent: str,
|
|
|
|
|
*,
|
|
|
|
|
success: bool,
|
|
|
|
|
execution_time_s: float,
|
|
|
|
|
tokens: int | None,
|
|
|
|
|
committed: bool | None,
|
|
|
|
|
head_after: str | None,
|
|
|
|
|
reason: str | None,
|
|
|
|
|
metadata: dict[str, Any] | None,
|
|
|
|
|
session_id: str | None,
|
|
|
|
|
) -> ExecutionRecord:
|
|
|
|
|
return ExecutionRecord(
|
|
|
|
|
timestamp=_utc_now(),
|
|
|
|
|
agent=agent,
|
|
|
|
|
success=success,
|
|
|
|
|
execution_time_s=float(execution_time_s),
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
metadata=metadata or {},
|
|
|
|
|
repo=root.name,
|
|
|
|
|
tokens=tokens,
|
|
|
|
|
committed=committed,
|
|
|
|
|
head_after=head_after,
|
|
|
|
|
reason=reason,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_external_executions(path: Path) -> list[dict[str, Any]]:
|
|
|
|
|
records: list[dict[str, Any]] = []
|
|
|
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
try:
|
|
|
|
|
value = json.loads(line)
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise OSError("external metrics ledger contains invalid JSON") from exc
|
|
|
|
|
if not isinstance(value, dict):
|
|
|
|
|
raise OSError("external metrics ledger contains a non-object record")
|
|
|
|
|
records.append(value)
|
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _state_dir() -> Path:
|
|
|
|
|
explicit = os.environ.get("REIN_AHARNESS_STATE_DIR", "").strip()
|
|
|
|
|
if explicit:
|
|
|
|
|
return Path(explicit).expanduser().resolve()
|
|
|
|
|
xdg = os.environ.get("XDG_STATE_HOME", "").strip()
|
|
|
|
|
if xdg:
|
|
|
|
|
return (Path(xdg).expanduser() / "rein-aharness").resolve()
|
|
|
|
|
return (Path.home() / ".local" / "state" / "rein-aharness").resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ensure_private_directory(path: Path) -> None:
|
|
|
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
current = path
|
|
|
|
|
while current.name and current != current.parent:
|
|
|
|
|
os.chmod(current, 0o700)
|
|
|
|
|
if current.name == "execution-metrics":
|
|
|
|
|
break
|
|
|
|
|
current = current.parent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _atomic_write_text(path: Path, value: str) -> None:
|
|
|
|
|
temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.tmp"
|
|
|
|
|
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
|
|
|
try:
|
|
|
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
|
|
|
fd = -1
|
|
|
|
|
handle.write(value)
|
|
|
|
|
handle.flush()
|
|
|
|
|
os.fsync(handle.fileno())
|
|
|
|
|
os.replace(temporary, path)
|
|
|
|
|
os.chmod(path, 0o600)
|
|
|
|
|
_fsync_directory(path.parent)
|
|
|
|
|
except BaseException:
|
|
|
|
|
if fd >= 0:
|
|
|
|
|
os.close(fd)
|
|
|
|
|
try:
|
|
|
|
|
temporary.unlink()
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
pass
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fsync_directory(path: Path) -> None:
|
|
|
|
|
fd = os.open(path, os.O_RDONLY)
|
|
|
|
|
try:
|
|
|
|
|
os.fsync(fd)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(fd)
|