Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions bin/pyauto-heart
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,13 @@ This NEVER dispatches a build, talks to GitHub, or mutates any repo. All
dispatching/polling/downloading is the Brain Release Agent's job; Heart is spec
+ ingest + verdict, credential-free.

The report carries release_ready (pass/fail axis), testpypi_version, profile,
per-repo commit_shas, per-stage status, per-project counts, failures and run
URLs. \`pyauto-heart readiness\` consumes it as a HARD gate: GREEN-for-release
The report carries validation_outcome (pass|fail|incomplete — the severity axis),
release_ready (the legacy boolean, kept for compatibility), testpypi_version,
profile, per-repo commit_shas, per-stage status, per-project counts, failures and
run URLs. \`pyauto-heart readiness\` consumes it as a HARD gate: GREEN-for-release
now requires a fresh passing report whose commit_shas match the current main
HEADs under the 'release' profile; absent/stale/mismatch → YELLOW; a failed
stage → RED.
HEADs under the 'release' profile; absent/stale/mismatch → YELLOW; incomplete
(nothing failed, no rehearsal evidence) → STALE; a failure → RED.

With no --ingest, prints the currently persisted validation_report.json.
EOF
Expand Down
5 changes: 4 additions & 1 deletion docs/release_validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ a failing local artifact remains RED.
`heart/validate.py` records `profile` and per-repo `commit_shas` in
`validation_report.json`; `heart/readiness.py` then requires, for GREEN:

- `release_ready == true` (no stage failed — else RED),
- `validation_outcome == pass` (else RED for `fail`; **STALE** for `incomplete`,
which means nothing failed and the rehearsal evidence is simply absent — a
report predating the field falls back to `release_ready == false` → RED, so
the gate fails closed on evidence it cannot classify),
- `profile == release` (else YELLOW — a smoke-fidelity run is not a release gate),
- `commit_shas` matching the current `main` HEADs (else YELLOW — stale source),
- freshness (a rehearsal older than `VALIDATION_STALE_DAYS` is YELLOW).
Expand Down
21 changes: 14 additions & 7 deletions health_agent/capabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,21 @@ release_validation:
`release-stage-report` artifact emitted by the release-integrate.yml
channel — collected and handed over by the Brain Release Agent.
produces: >-
validation_report.json (schema_version 1): release_ready, testpypi_version,
profile, per-repo commit_shas, per-stage status, per-project counts,
failures, run URLs, timestamp — persisted in Heart state + a
validation_history/ archive.
validation_report.json (schema_version 1): validation_outcome
(pass|fail|incomplete), release_ready (legacy boolean, kept for
compatibility), testpypi_version, profile, per-repo commit_shas, per-stage
status, per-project counts, failures, run URLs, timestamp — persisted in
Heart state + a validation_history/ archive.
gate_role: >-
HARD readiness gate. release_ready==false → RED; absent / stale-by-age /
commit_shas not matching current main HEADs / profile != release → YELLOW;
fresh pass matching current source under the release profile → GREEN.
HARD readiness gate. Read validation_outcome, NOT release_ready — the
boolean collapses "a stage failed" and "no rehearsal evidence" into one
false, and the tick's integrate-only auto-ingest always lands on that
false however green the run was. validation_outcome==fail → RED;
==incomplete → STALE (an evidence gap, nothing known-bad); absent /
stale-by-age / commit_shas not matching current main HEADs / profile !=
release → YELLOW; fresh pass matching current source under the release
profile → GREEN. A report predating validation_outcome falls back to
release_ready==false → RED (fail closed).
orchestrated_by: "PyAutoBrain Release Agent (agents/release/) — dispatch/poll/download"
boundary: "ingest-and-judge only; never dispatches release.yml or workspace-validation.yml"

Expand Down
117 changes: 106 additions & 11 deletions heart/checks/release_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@

- a fresher local ingest is never regressed (report ts vs run creation time);
- a run already ingested (sidecar-cached id) is never re-downloaded;
- a FAILED rehearsal ingests too: ``release_ready: false`` is evidence, not an
evidence gap — readiness then shows the accurate ``release validation
- a FAILED rehearsal ingests too: ``validation_outcome: "fail"`` is evidence,
not an evidence gap — readiness then shows the accurate ``release validation
FAILED (stage integrate)`` instead of week-old STALE, and it self-clears on
the next green night.

The artifact this check downloads is an **integrate-only** stage report, so the
folded report can never carry a ``rehearse`` stage and its ``release_ready`` is
``false`` by construction — however green the run was. That is why severity is
read from ``validation_outcome`` (``incomplete``, an evidence gap → STALE) and
not from the boolean, which would report a failure that never happened.

``decide()`` is pure and no-network: the gh-backed callables are injected only
by the ``main()`` tick/CLI entrypoint (the #83/#120 discipline).
"""
Expand Down Expand Up @@ -119,6 +125,12 @@ def decide(

Actions: no-runs · in-progress · cached (already ingested) ·
local-fresher (never regress a newer local ingest) · ingest.

A report that predates ``validation_outcome`` is re-ingested once even when
the run id is cached: without that, a report already folded by the old code
would keep its missing discriminator forever, and the readiness gate — which
fails closed on reports it cannot classify — would stay RED until some
unrelated future run happened to come along.
"""
if not run_record:
return {"action": "no-runs"}
Expand All @@ -131,15 +143,83 @@ def decide(
}
if run_record.get("status") != "completed":
return {**out, "action": "in-progress"}
if isinstance(sidecar, dict) and sidecar.get("last_ingested_run_id") == run_id:
return {**out, "action": "cached"}
report_ts = _parse_ts((current_report or {}).get("ts"))
created = _parse_ts(out["created"])
if report_ts is not None and created is not None and report_ts >= created:
return {**out, "action": "local-fresher"}
# A one-time re-fold for reports written before `validation_outcome` existed.
#
# Strictly limited to reports this check's own integrate-only artifact can
# reproduce in full, because the re-fold OVERWRITES the canonical report:
#
# - skipped when a `rehearse` stage is present — evidence from a manual
# multi-stage ingest during a release drive, which this artifact cannot
# reproduce; re-folding would turn its `pass` into an `incomplete`;
# - skipped when the stored report carries ANY adverse evidence — a failed
# stage, failing/timed-out counts in `totals` OR in any `per_project`
# entry, or a failures list. Those can come from a run that broke before
# it ever reached the rehearsal, and re-folding a green artifact over them
# would silently convert a real RED into a STALE. The definition of
# "adverse" must match `validate._Accumulator._has_adverse_evidence`;
# when it did not, per-project failures were an escape hatch.
# - triggered only when the field is genuinely ABSENT, not merely invalid.
# A present-but-malformed discriminator is graded RED on purpose, so
# treating it as "predates the schema" would let the migration overwrite
# the very report that RED rests on.
#
# Note the test is neither `cached` nor `local-fresher`: every ingest happens
# after the run it ingests, so "the report is fresher than the run" says
# nothing about where the report came from.
from heart import validate

report = current_report if isinstance(current_report, dict) else {}
stages = report.get("stages")
stages = stages if isinstance(stages, dict) else {}

def _adverse_counts(counts: Any) -> bool:
return bool(
isinstance(counts, dict)
and (counts.get("failed", 0) or counts.get("timeout", 0))
)

per_project = report.get("per_project")
per_project = per_project if isinstance(per_project, dict) else {}
stored_adverse = (
# Normalise exactly as the ingest does. A stored report can carry a
# synonym ("failure", "timed_out"), which the accumulator folds to
# `fail`; matching only the literal token here let such a report look
# benign and be overwritten by a green artifact.
any(
isinstance(s, dict) and validate._norm_status(s.get("status")) == "fail"
for s in stages.values()
)
or _adverse_counts(report.get("totals"))
or any(_adverse_counts(c) for c in per_project.values())
or bool(report.get("failures"))
)
stale_schema = (
bool(report)
and "rehearse" not in stages
and not stored_adverse
and "validation_outcome" not in report
)
if not stale_schema:
if isinstance(sidecar, dict) and sidecar.get("last_ingested_run_id") == run_id:
return {**out, "action": "cached"}
report_ts = _parse_ts(report.get("ts"))
created = _parse_ts(out["created"])
if report_ts is not None and created is not None and report_ts >= created:
return {**out, "action": "local-fresher"}
return {**out, "action": "ingest"}


def resolve_outcome(ingested: dict[str, Any] | None) -> str:
"""``pass`` | ``fail`` | ``incomplete`` for an ingested report.

Pure, like ``decide()``, so the tick's wording is testable without the
network. Reports predating ``validation_outcome`` fall back to the legacy
boolean and fail closed.
"""
from heart import validate
return validate.report_outcome(ingested) or "fail"


def main(argv: list[str] | None = None) -> int:
sys.path.insert(0, str(HEART_HOME))
from heart import state, validate
Expand All @@ -154,22 +234,37 @@ def main(argv: list[str] | None = None) -> int:
if report_path is None:
action = decision["action"] = "artifact-unavailable"
else:
ingested = validate.run([td])
# A run whose conclusion is not `success` is a failure even if
# the stage report it uploaded says otherwise — the workflow can
# break outside anything that artifact captures, and the artifact
# is written by a step that may have run before the break.
ingested = validate.run(
[td],
force_fail=str(decision.get("conclusion") or "").lower() != "success",
)
# Record the ingest so the tick never re-downloads this run.
state.atomic_write_json(SIDECAR, {
"last_ingested_run_id": decision.get("run_id"),
"ingested_ts": ingested.get("ts"),
"release_ready": ingested.get("release_ready"),
"validation_outcome": ingested.get("validation_outcome"),
"run_url": decision.get("url"),
})

from heart.heart_color import c_fail, c_info, c_meta, c_ok, c_warn, glyph_fail, glyph_ok, glyph_warn

label_id = decision.get("run_id", "?")
if action == "ingest":
ready = (ingested or {}).get("release_ready")
if ready is True:
outcome = resolve_outcome(ingested)
if outcome == "pass":
glyph, label = glyph_ok(), c_ok(f"rehearsal ingested (run {label_id}: pass)")
elif outcome == "incomplete":
# This is the ordinary state for this path: the artifact is an
# integrate-only stage report, so it carries no rehearsal evidence.
# Nothing failed — do not say FAILED.
glyph, label = glyph_warn(), c_warn(
f"integrate ingested (run {label_id}: no rehearsal evidence)"
)
else:
glyph, label = glyph_fail(), c_fail(f"rehearsal ingested (run {label_id}: FAILED)")
elif action in ("cached", "local-fresher"):
Expand Down
23 changes: 19 additions & 4 deletions heart/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from dataclasses import dataclass, field
from typing import Any, Iterable, Sequence

from heart import validate
from heart.checks.test_run import counts_measured as tr_counts_measured
from heart.heart_color import (
c_bold, c_dim, c_fail, c_info, c_meta, c_ok, c_warn,
Expand Down Expand Up @@ -569,15 +570,25 @@ def build_board(
ready = vr.get("release_ready")
ver = vr.get("testpypi_version") or "?"
profile = vr.get("profile") or "?"
stages = vr.get("stages") or {}
stages = vr.get("stages")
stages = stages if isinstance(stages, dict) else {}
meta = f"v{ver} profile={profile} ({vr.get('ts', '?')})"
if ready is False:
# Same normaliser the readiness gate uses, so this row can never
# contradict the header verdict. `incomplete` is an evidence gap (WARN),
# not a failure (FAIL) — a green integrate-only ingest lands there, and
# a FAIL row beside a stale header reads as a broken release.
outcome = validate.report_outcome(vr)
if outcome == "fail":
st, summary = FAIL, f"NOT release_ready — {meta}"
elif ready is True:
elif outcome == "incomplete":
st, summary = WARN, f"incomplete — no rehearsal evidence — {meta}"
elif outcome == "pass":
st, summary = OK, f"release_ready — {meta}"
else:
st, summary = WARN, f"release_ready unknown — {meta}"
details = [f"stages: " + ", ".join(f"{n}:{s.get('status', '?')}" for n, s in stages.items())] \
details = [f"stages: " + ", ".join(
f"{n}:{s.get('status', '?') if isinstance(s, dict) else '?'}"
for n, s in stages.items())] \
if stages else []
sections.append(Section("release_validation", "Release validation", st, summary, details))

Expand Down Expand Up @@ -798,6 +809,10 @@ def to_dict(board: Board) -> dict[str, Any]:
"stale": board.stale,
"red_reasons": board.red_reasons,
"yellow_reasons": board.yellow_reasons,
# Evidence gaps belong on this surface too: the Health Agent and mobile
# read it, and a reason that moves from the red axis to the stale one
# would otherwise vanish from both rather than being re-classified.
"stale_reasons": board.stale_reasons,
"pages_url": PAGES_URL,
"sections": [
{
Expand Down
40 changes: 29 additions & 11 deletions heart/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
exceeds the newest released version of its library (UNSATISFIABLE — no
installable release can satisfy it), or an unparseable (BAD) floor/tag; or the
deep install verification last reported ``ready == false``; or the
release-validation report last ingested reports ``release_ready == false`` (a
stage failed).
release-validation report last ingested reports ``validation_outcome ==
"fail"`` (a stage failed, or failing/timed-out counts were recorded). A report
predating that field falls back to ``release_ready == false`` → RED, so the
gate fails closed on evidence it cannot classify.
- **YELLOW** (caution) for soft signals: workspace-validation not passing (the
workspace scripts/notebooks carry standing debt, so this is advisory — never a
hard block), script-timing regressions, stale open PRs, stale parked scripts, a
Expand All @@ -30,7 +32,9 @@
- **STALE** (an evidence gap, the freshness tier) when nothing is known-bad but
some evidence is *missing or expired*: a check that was never run, a
passing-but-aged report, a rehearsal whose ``commit_shas`` no longer match
``main``, an unknown repo/version status. The remedy for a stale reason is to
``main``, a validation report whose ``validation_outcome`` is ``incomplete``
(nothing failed; the rehearsal evidence is absent), an unknown repo/version
status. The remedy for a stale reason is to
**re-run the check**, never to fix code — which is exactly what separates it
from yellow. The tier is not a skip lever: evidence whose *last known result
was adverse* stays yellow/red until a fresh run says otherwise; only
Expand Down Expand Up @@ -83,7 +87,7 @@

import yaml

from heart import state
from heart import state, validate
from heart.checks.ci_status import FAILURE_CONCLUSIONS, load_required_workflows
from heart.checks.test_run import counts_measured as tr_counts_measured
from heart.heart_color import (
Expand Down Expand Up @@ -453,23 +457,37 @@ def scope_local(msg: str, key: str) -> None:
# This is the M2 gate: the report proves the exact source about to ship was
# built, published to TestPyPI, installed from the wheel, and exercised at
# release fidelity. Absent/stale/source-not-matching → YELLOW ("no release
# rehearsal for current source"); failing → RED. Pass/fail (release_ready)
# is the RED axis; fidelity+freshness (profile / commit_shas / age) is the
# YELLOW axis — a passing-but-stale report is a caution, not a blocker.
# rehearsal for current source"); failing → RED. `validation_outcome` is the
# RED axis; fidelity+freshness (profile / commit_shas / age) is the YELLOW
# axis — a passing-but-stale report is a caution, not a blocker.
#
# Read `validation_outcome`, NOT `release_ready`: the boolean collapses
# "something failed" and "the rehearsal evidence is missing" into one
# `false`, and the tick's integrate-only auto-ingest always lands on that
# `false` (see heart/validate.py). Grading it RED reported a failure that
# had not happened. A report predating the field carries no discriminator,
# so `false` there stays RED — fail closed.
vr = snapshot.get("validation_report")
if isinstance(vr, dict) and vr:
ready = vr.get("release_ready")
if ready is False:
outcome = validate.report_outcome(vr)
if outcome == "incomplete":
# Nothing is wrong; the rehearsal evidence is simply absent. The
# wording must contain "release validation" — the Health Agent
# classifier matches on that string to route the remedy.
stale.append("release validation incomplete: no rehearsal for current source")
hit("validation_absent")
elif outcome == "fail":
vr_stages = vr.get("stages")
failed_stages = [
n for n, s in (vr.get("stages") or {}).items()
n for n, s in (vr_stages if isinstance(vr_stages, dict) else {}).items()
if isinstance(s, dict) and s.get("status") == "fail"
]
red.append(
"release validation FAILED"
+ (f" (stage {', '.join(failed_stages)})" if failed_stages else "")
)
hit("validation_failed")
elif ready is True:
elif outcome == "pass":
commit_shas = vr.get("commit_shas") or {}
mismatched: list[str] = []
unconfirmed: list[str] = []
Expand Down
Loading
Loading