feat(plane): memory store (file and MinIO) and the remember task (harnessx-removal task 2)

plane/memory.py: the MemoryStore interface with FileStore (index.jsonl, facts by
content hash, repos/<host>__<owner>__<name>/<pin>.jsonl, idempotent ingest,
deterministic row ids, refusal below 1 GiB free naming the path) and MinioStore
(object metadata and tags, S3 Select pushdown probed once per process with a
logged local fallback, object version ids on every row read, versioning and
runs/ lifecycle helper, presigned URLs). OUSAST_MEMORY selects the backend;
MINIO_* settings come from .env; the minio SDK is the optional extra `minio`.

plane/tasks/remember.py: model-free rows (facts, verdict, unit_cost, alert)
from a case's artifacts; remember_run computes them on the host for a recorded
run without writing into it. reconciler.mark_done (477 lines total), seed
before and ingest after `ousast plane run`, `ousast plane remember <run>`.
The generator annotates each repo-facts Task with openultrasast.io/memory-key
(repo, pin, candidates digest, image digest) and the Run with its population
and split; plane/ regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-30 11:10:28 +02:00
co-authored by Claude Opus 5.5
parent d64ab2817e
commit 52d28b5121
31 changed files with 2121 additions and 14 deletions
+246
View File
@@ -0,0 +1,246 @@
"""`remember` (harnessx-removal Requirement 6.1, design section 4): a case's delivered artifacts as memory rows.
No model, budget ``{usd: 0, calls: 0}``, one per case. It reads the case's `facts.json`, the `units.jsonl` of the
verify passes a, b and c, the final `agreed.json` (its per-candidate rows and `disputed` list) and, when present,
`alerts.jsonl`, and writes `memory.jsonl` -- rows of the kinds `facts`, `verdict`, `unit_cost` and `alert` (the
fifth kind, `proposal_outcome`, comes from the loop) -- and passes `facts.json` through. Task code interprets the
artifacts, so the reconciler and the runner stay content-blind (ai-service-plane Req 3.4).
Every row carries `id` (deterministic: :func:`..memory.row_id`), `kind`, `repo`, `pin`, `run`, `task`,
`population`, `split` and `image` (the runner image digest the case's tasks ran on).
Env contract in a Run: `OUSAST_OUTPUT_DIR`, `OUSAST_RUN`, `OUSAST_TASK`, `OUSAST_INPUT_FACTS`,
`OUSAST_INPUT_PASS_A`/`_B`, optional `OUSAST_INPUT_PASS_C`, `OUSAST_INPUT_SUMMARY_A`/`_B`/`_C` (the passes'
`summary.json`, for the model), `OUSAST_INPUT_AGREED`, `OUSAST_INPUT_ALERTS`; the repository and pin from the bound
Workspace (`AX_WORKSPACES_YAML`, `OUSAST_GIT_PINS`), the image from `AX_TASK_YAML`, the population and split from
`OUSAST_POPULATION`/`OUSAST_SPLIT` (the generator copies the Run's `openultrasast.io/population` and
`openultrasast.io/split` annotations). Exit 0 done, 2 failed.
On the host, :func:`remember_run` does the same over a recorded run directory (``ousast plane remember <run>``):
per case without a delivered `memory.jsonl` it reads the artifacts and the rendered `task.yaml` beside them and
ingests the rows directly, writing nothing into the run directory.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import traceback
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from ..memory import REMEMBER_OUTPUT, IngestResult, MemoryStore, image_digest, ingest, repo_key, row_id, validate_row
from .repo_facts import _digest
PASSES = ("a", "b", "c")
UNKNOWN = "unknown"
@dataclass(frozen=True)
class Context:
run: str
task: str
repo: str
pin: str
image: str
population: str = UNKNOWN
split: str = UNKNOWN
def row(self, kind: str, subject: str, **fields: Any) -> dict[str, Any]:
base = {
"id": row_id(kind, self.run, self.task, subject), "kind": kind, "repo": repo_key(self.repo), "pin": self.pin,
"run": self.run, "task": self.task, "population": self.population, "split": self.split, "image": self.image,
} # fmt: skip
return {**base, **fields}
def candidates_digest(facts: Mapping[str, Any]) -> str:
"""`repo_facts._digest` of the candidate names `facts.json` was computed for (its `callers` keys)."""
names = sorted({str(key).partition("::")[2] for key in (facts.get("callers") or {})})
return _digest(names)
def _final(row: Mapping[str, Any], disputed: set[str]) -> str:
if row.get("agreed"):
return "agreed"
return "disputed" if str(row["candidate"]) in disputed else "rejected"
def rows_for(
ctx: Context,
*,
facts: bytes | None,
passes: Mapping[str, Sequence[Mapping[str, Any]]],
models: Mapping[str, str | None],
agreed: Mapping[str, Any] | None,
alerts: Sequence[Mapping[str, Any]] = (),
) -> list[dict[str, Any]]:
"""The memory rows of one case: facts, verdicts, unit costs, alerts."""
rows: list[dict[str, Any]] = []
if facts is not None:
loaded = json.loads(facts)
sha = hashlib.sha256(facts).hexdigest()
counts = loaded.get("counts") or {}
files = int(counts.get("files", len(loaded.get("files") or {})))
rows.append(ctx.row("facts", sha, sha256=sha, candidates_digest=candidates_digest(loaded), files=files))
if agreed is not None:
disputed = {str(d["candidate"]) for d in agreed.get("disputed") or []}
for cand in agreed.get("candidates") or []:
finding = cand.get("finding") or {}
fields = {
"candidate": cand["candidate"], "family": finding.get("family") or agreed.get("family"), "site": cand.get("site"),
"passes": {p: cand.get(p) for p in PASSES}, "final": _final(cand, disputed), "tiebreak": cand.get("c") is not None,
"site_match": cand.get("site_match"), "usd": cand.get("usd"), "turns": cand.get("turns"),
} # fmt: skip
rows.append(ctx.row("verdict", str(cand["candidate"]), **fields))
for label in PASSES:
for unit in passes.get(label) or []:
usage = dict(unit.get("usage") or {})
fields = {
"pass": label, "path": unit.get("path"), "usd": unit.get("usd"), "calls": usage.get("calls"), "usage": usage,
"turns": unit.get("turns"), "model": models.get(label),
} # fmt: skip
rows.append(ctx.row("unit_cost", f"{label}:{unit.get('path')}", **fields))
for alert in alerts:
fields = {k: alert.get(k) for k in ("rule_id", "rule_status", "path", "line", "function", "pin_role")}
rows.append(ctx.row("alert", f"{fields['rule_id']}:{fields['path']}:{fields['line']}", **fields))
return [validate_row(r, f"{ctx.task} {r['kind']}") for r in rows]
def _jsonl(path: Path | None) -> list[dict[str, Any]]:
if path is None or not path.is_file():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def _json(path: Path | None) -> Any:
return json.loads(path.read_text(encoding="utf-8")) if path is not None and path.is_file() else None
def _model(summary: object) -> str | None:
return str(summary["model"]) if isinstance(summary, dict) and summary.get("model") else None
def context_from(
task_doc: Mapping[str, Any], workspaces: Sequence[Mapping[str, Any]], *, run: str, task: str, population: str, split: str
) -> Context:
"""Repository, pin and image from a rendered Task and its Workspaces: the pin named in `OUSAST_GIT_PINS`, the
repository of that Workspace's git entry, the Task's image."""
spec = task_doc.get("spec") or {}
env = {str(e.get("name")): str(e.get("value")) for e in spec.get("env") or []}
pins: dict[str, str] = json.loads(env.get("OUSAST_GIT_PINS") or "{}")
if len(pins) != 1:
raise ValueError(f"{task}: expected one pinned repository in OUSAST_GIT_PINS, got {sorted(pins)}")
((ref, pin),) = pins.items()
ws_name, _, git_name = ref.partition("/")
repos = [
str(g["repo"])
for w in workspaces if (w.get("metadata") or {}).get("name") == ws_name
for g in (w.get("spec") or {}).get("git") or [] if g.get("name") == git_name
] # fmt: skip
if not repos:
raise ValueError(f"{task}: no bound Workspace git entry {ref}")
image = str(spec.get("image") or "")
if not image:
raise ValueError(f"{task}: the Task names no image")
return Context(run, task, repos[0], pin, image_digest(image), population or UNKNOWN, split or UNKNOWN)
def _rendered(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
docs = [d for d in yaml.safe_load_all(path.read_text(encoding="utf-8")) if isinstance(d, dict)]
tasks = [d for d in docs if d.get("kind") == "Task"]
if not tasks:
raise ValueError(f"{path}: no rendered Task")
return tasks[-1], [d for d in docs if d.get("kind") == "Workspace"]
def case_rows(base: Path, case: str, *, population: str, split: str) -> tuple[list[dict[str, Any]], bytes | None]:
"""The rows of one case of a recorded run directory, from its artifacts and the rendered `task.yaml`."""
facts_dir = base / f"{case}-facts"
candidates = [base / f"{case}-{step}" / "task.yaml" for step in ("facts", "va", "vb", "agree")]
rendered = next((path for path in candidates if path.is_file()), None)
if rendered is None:
raise ValueError(f"{base}: case {case} has no rendered task.yaml")
task_doc, workspaces = _rendered(rendered)
ctx = context_from(task_doc, workspaces, run=base.name, task=f"{case}-remember", population=population, split=split)
reused = (_json(facts_dir / "summary.json") or {}).get("reused")
if isinstance(reused, dict) and reused.get("image"):
ctx = Context(ctx.run, ctx.task, ctx.repo, ctx.pin, image_digest(str(reused["image"])), ctx.population, ctx.split)
facts = (facts_dir / "facts.json").read_bytes() if (facts_dir / "facts.json").is_file() else None
final = base / f"{case}-final" / "agreed.json"
agreed = _json(final if final.is_file() else base / f"{case}-agree" / "agreed.json")
passes = {p: _jsonl(base / f"{case}-v{p}" / "units.jsonl") for p in PASSES}
models = {p: _model(_json(base / f"{case}-v{p}" / "summary.json")) for p in PASSES}
alerts = _jsonl(base / f"{case}-alerts" / "alerts.jsonl")
return rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=alerts), facts
def remember_run(run_dir: Path, store: MemoryStore, *, population: str = UNKNOWN, split: str = UNKNOWN) -> list[IngestResult]:
"""Ingest a run: delivered `memory.jsonl` files as they are, and every other case (a `<case>-facts` task)
computed here from its artifacts."""
base = Path(run_dir)
if not base.is_dir():
raise ValueError(f"no run directory at {base}")
results = ingest(base, store)
delivered = {r.task for r in results}
for facts_dir in sorted(base.glob("*-facts")):
case = facts_dir.name.removesuffix("-facts")
if f"{case}-remember" in delivered or (base / f"{case}-remember" / REMEMBER_OUTPUT).is_file():
continue
rows, facts = case_rows(base, case, population=population, split=split)
results.append(store.ingest_rows(base.name, f"{case}-remember", rows, facts))
return results
def _path(env: Mapping[str, str], name: str) -> Path | None:
return Path(env[name]) if env.get(name) else None
def _units(path: Path | None) -> list[dict[str, Any]]:
return _jsonl(path / "units.jsonl" if path is not None and path.is_dir() else path)
def main(environ: Mapping[str, str] | None = None) -> int:
env = environ if environ is not None else os.environ
output_dir = _path(env, "OUSAST_OUTPUT_DIR")
summary: dict[str, Any] = {"status": "failed", "units_done": 0, "units_total": 1, "usd": 0, "calls": 0, "usage": {}, "model": None}
try:
if output_dir is None:
raise ValueError("OUSAST_OUTPUT_DIR is not set")
output_dir.mkdir(parents=True, exist_ok=True)
task_doc = yaml.safe_load(env.get("AX_TASK_YAML") or "{}") or {}
task_doc.setdefault("spec", {})["env"] = [{"name": "OUSAST_GIT_PINS", "value": env.get("OUSAST_GIT_PINS") or "{}"}]
workspaces = [d for d in yaml.safe_load_all(env.get("AX_WORKSPACES_YAML") or "") if isinstance(d, dict)]
ctx = context_from(
task_doc, workspaces, run=env.get("OUSAST_RUN") or UNKNOWN, task=env.get("OUSAST_TASK") or "remember",
population=env.get("OUSAST_POPULATION") or UNKNOWN, split=env.get("OUSAST_SPLIT") or UNKNOWN,
) # fmt: skip
facts_path = _path(env, "OUSAST_INPUT_FACTS")
facts = facts_path.read_bytes() if facts_path is not None and facts_path.is_file() else None
passes = {p: _units(_path(env, f"OUSAST_INPUT_PASS_{p.upper()}")) for p in PASSES}
models = {p: _model(_json(_path(env, f"OUSAST_INPUT_SUMMARY_{p.upper()}"))) for p in PASSES}
agreed = _json(_path(env, "OUSAST_INPUT_AGREED"))
rows = rows_for(ctx, facts=facts, passes=passes, models=models, agreed=agreed, alerts=_jsonl(_path(env, "OUSAST_INPUT_ALERTS")))
(output_dir / REMEMBER_OUTPUT).write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8")
if facts is not None:
(output_dir / "facts.json").write_bytes(facts)
summary.update(status="done", units_done=1, rows=len(rows))
except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback
summary["reason"] = traceback.format_exc()[-2000:]
if output_dir is not None:
(output_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({k: summary.get(k) for k in ("status", "rows")}), file=sys.stderr)
return 0 if summary["status"] == "done" else 2
__all__ = ["Context", "candidates_digest", "case_rows", "context_from", "main", "remember_run", "rows_for"]
if __name__ == "__main__":
raise SystemExit(main())