Skip to content

Commit 17598d1

Browse files
Jammy2211claude
authored andcommitted
fix: grade an absent rehearsal as STALE, not RED (#144)
Heart reported RED `release validation FAILED` against a Release Integrate run that was entirely green: 661 passed, 0 failed, `failures: []`. The tick's auto-ingest (`release_run.py`) folds the latest release-integrate run's stage report on its own, with no merge base. That artifact is integrate-only — `to_stage_report()` emits a single top-level `stage` — so it can never carry a `rehearse` stage, and `_Accumulator.release_ready()` requires one. `release_ready` was therefore false by construction on this path however green the run was, and `readiness.py` mapped every false to the RED axis. One boolean cannot answer both "did anything fail?" and "is the evidence complete?". Add `validation_outcome: pass|fail|incomplete` and read that for severity; `release_ready` is unchanged beside it for compatibility. - validate.py: emit `validation_outcome`. `fail` covers any adverse evidence — a failed stage, positive `totals.failed`/`totals.timeout`, or a non-empty `failures` list — not just a stage literally marked "fail", which `release_ready()` alone tested. Also apply `_norm_status` in `add_report`, which previously copied stage statuses verbatim, so a merged report's "failure" never normalised and read as not-a-failure. - readiness.py: `fail` -> RED (unchanged), `incomplete` -> STALE. A report predating the field falls back to `release_ready` and stays RED — fail closed. The STALE text keeps the literal "release validation" so the Health Agent classifier routes it to the `validate` capability instead of `unknown`. - dashboard.py: render `incomplete` as WARN, not a FAIL row beside a stale header; add `stale_reasons` to `to_dict()`, the surface the Health Agent and mobile read, so a reason moved onto the stale axis is re-reported rather than disappearing. - release_run.py: stop calling every non-true ingest "FAILED", and re-fold once when the stored report predates the discriminator — otherwise the RED would persist behind the run-id cache. That migration is skipped when the report already holds a `rehearse` stage, which this check's artifact cannot reproduce, so a release drive's manual multi-stage ingest is never regressed. Verified end-to-end against the real state and the real artifact, with no CI re-dispatched: RED 45 / `release validation FAILED` -> YELLOW 70 / `release validation incomplete: no rehearsal for current source`, red_reasons empty. The remaining yellows are the pre-existing workspace-validation and manifest-drift ones. Regression tests pin the fail-closed cases a naive "no stage says fail" test would have softened: positive failed counts, positive timeout counts, a non-empty failures list, a "failure" status synonym arriving via `add_report`, and a legacy report with no discriminator. The pre-existing `test_validation_failed_is_red` is unmodified and serves as the control. Diagnosis and plan were cross-reviewed by Codex, which rejected an earlier readiness-only fix; its load-bearing findings were re-verified against the code before being adopted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ENfMeYpRfAifDQ63uheMbV
1 parent c5706ee commit 17598d1

10 files changed

Lines changed: 562 additions & 39 deletions

File tree

docs/release_validation.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ a failing local artifact remains RED.
138138
`heart/validate.py` records `profile` and per-repo `commit_shas` in
139139
`validation_report.json`; `heart/readiness.py` then requires, for GREEN:
140140

141-
- `release_ready == true` (no stage failed — else RED),
141+
- `validation_outcome == pass` (else RED for `fail`; **STALE** for `incomplete`,
142+
which means nothing failed and the rehearsal evidence is simply absent — a
143+
report predating the field falls back to `release_ready == false` → RED, so
144+
the gate fails closed on evidence it cannot classify),
142145
- `profile == release` (else YELLOW — a smoke-fidelity run is not a release gate),
143146
- `commit_shas` matching the current `main` HEADs (else YELLOW — stale source),
144147
- freshness (a rehearsal older than `VALIDATION_STALE_DAYS` is YELLOW).

health_agent/capabilities.yaml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,21 @@ release_validation:
128128
`release-stage-report` artifact emitted by the release-integrate.yml
129129
channel — collected and handed over by the Brain Release Agent.
130130
produces: >-
131-
validation_report.json (schema_version 1): release_ready, testpypi_version,
132-
profile, per-repo commit_shas, per-stage status, per-project counts,
133-
failures, run URLs, timestamp — persisted in Heart state + a
134-
validation_history/ archive.
131+
validation_report.json (schema_version 1): validation_outcome
132+
(pass|fail|incomplete), release_ready (legacy boolean, kept for
133+
compatibility), testpypi_version, profile, per-repo commit_shas, per-stage
134+
status, per-project counts, failures, run URLs, timestamp — persisted in
135+
Heart state + a validation_history/ archive.
135136
gate_role: >-
136-
HARD readiness gate. release_ready==false → RED; absent / stale-by-age /
137-
commit_shas not matching current main HEADs / profile != release → YELLOW;
138-
fresh pass matching current source under the release profile → GREEN.
137+
HARD readiness gate. Read validation_outcome, NOT release_ready — the
138+
boolean collapses "a stage failed" and "no rehearsal evidence" into one
139+
false, and the tick's integrate-only auto-ingest always lands on that
140+
false however green the run was. validation_outcome==fail → RED;
141+
==incomplete → STALE (an evidence gap, nothing known-bad); absent /
142+
stale-by-age / commit_shas not matching current main HEADs / profile !=
143+
release → YELLOW; fresh pass matching current source under the release
144+
profile → GREEN. A report predating validation_outcome falls back to
145+
release_ready==false → RED (fail closed).
139146
orchestrated_by: "PyAutoBrain Release Agent (agents/release/) — dispatch/poll/download"
140147
boundary: "ingest-and-judge only; never dispatches release.yml or workspace-validation.yml"
141148

heart/checks/release_run.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@
1616
1717
- a fresher local ingest is never regressed (report ts vs run creation time);
1818
- a run already ingested (sidecar-cached id) is never re-downloaded;
19-
- a FAILED rehearsal ingests too: ``release_ready: false`` is evidence, not an
20-
evidence gap — readiness then shows the accurate ``release validation
19+
- a FAILED rehearsal ingests too: ``validation_outcome: "fail"`` is evidence,
20+
not an evidence gap — readiness then shows the accurate ``release validation
2121
FAILED (stage integrate)`` instead of week-old STALE, and it self-clears on
2222
the next green night.
2323
24+
The artifact this check downloads is an **integrate-only** stage report, so the
25+
folded report can never carry a ``rehearse`` stage and its ``release_ready`` is
26+
``false`` by construction — however green the run was. That is why severity is
27+
read from ``validation_outcome`` (``incomplete``, an evidence gap → STALE) and
28+
not from the boolean, which would report a failure that never happened.
29+
2430
``decide()`` is pure and no-network: the gh-backed callables are injected only
2531
by the ``main()`` tick/CLI entrypoint (the #83/#120 discipline).
2632
"""
@@ -119,6 +125,12 @@ def decide(
119125
120126
Actions: no-runs · in-progress · cached (already ingested) ·
121127
local-fresher (never regress a newer local ingest) · ingest.
128+
129+
A report that predates ``validation_outcome`` is re-ingested once even when
130+
the run id is cached: without that, a report already folded by the old code
131+
would keep its missing discriminator forever, and the readiness gate — which
132+
fails closed on reports it cannot classify — would stay RED until some
133+
unrelated future run happened to come along.
122134
"""
123135
if not run_record:
124136
return {"action": "no-runs"}
@@ -131,15 +143,46 @@ def decide(
131143
}
132144
if run_record.get("status") != "completed":
133145
return {**out, "action": "in-progress"}
134-
if isinstance(sidecar, dict) and sidecar.get("last_ingested_run_id") == run_id:
135-
return {**out, "action": "cached"}
136-
report_ts = _parse_ts((current_report or {}).get("ts"))
137-
created = _parse_ts(out["created"])
138-
if report_ts is not None and created is not None and report_ts >= created:
139-
return {**out, "action": "local-fresher"}
146+
# A one-time re-fold for reports written before `validation_outcome` existed.
147+
#
148+
# Skipped when the current report already carries a `rehearse` stage: that is
149+
# evidence this check's integrate-only artifact cannot reproduce (it comes
150+
# from a manual multi-stage ingest during a release drive), so re-folding
151+
# would throw it away and turn a `pass` into an `incomplete`. Note the test
152+
# is the rehearsal, NOT `local-fresher` — every ingest necessarily happens
153+
# after the run it ingests, so a report being "fresher than the run" says
154+
# nothing about where it came from.
155+
stages = (current_report or {}).get("stages")
156+
has_rehearsal = isinstance(stages, dict) and "rehearse" in stages
157+
stale_schema = (
158+
isinstance(current_report, dict)
159+
and bool(current_report)
160+
and not has_rehearsal
161+
and current_report.get("validation_outcome") not in ("pass", "fail", "incomplete")
162+
)
163+
if not stale_schema:
164+
if isinstance(sidecar, dict) and sidecar.get("last_ingested_run_id") == run_id:
165+
return {**out, "action": "cached"}
166+
report_ts = _parse_ts((current_report or {}).get("ts"))
167+
created = _parse_ts(out["created"])
168+
if report_ts is not None and created is not None and report_ts >= created:
169+
return {**out, "action": "local-fresher"}
140170
return {**out, "action": "ingest"}
141171

142172

173+
def resolve_outcome(ingested: dict[str, Any] | None) -> str:
174+
"""``pass`` | ``fail`` | ``incomplete`` for an ingested report.
175+
176+
Pure, like ``decide()``, so the tick's wording is testable without the
177+
network. Reports predating ``validation_outcome`` fall back to the legacy
178+
boolean and fail closed.
179+
"""
180+
outcome = (ingested or {}).get("validation_outcome")
181+
if outcome in ("pass", "fail", "incomplete"):
182+
return str(outcome)
183+
return "pass" if (ingested or {}).get("release_ready") is True else "fail"
184+
185+
143186
def main(argv: list[str] | None = None) -> int:
144187
sys.path.insert(0, str(HEART_HOME))
145188
from heart import state, validate
@@ -160,16 +203,24 @@ def main(argv: list[str] | None = None) -> int:
160203
"last_ingested_run_id": decision.get("run_id"),
161204
"ingested_ts": ingested.get("ts"),
162205
"release_ready": ingested.get("release_ready"),
206+
"validation_outcome": ingested.get("validation_outcome"),
163207
"run_url": decision.get("url"),
164208
})
165209

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

168212
label_id = decision.get("run_id", "?")
169213
if action == "ingest":
170-
ready = (ingested or {}).get("release_ready")
171-
if ready is True:
214+
outcome = resolve_outcome(ingested)
215+
if outcome == "pass":
172216
glyph, label = glyph_ok(), c_ok(f"rehearsal ingested (run {label_id}: pass)")
217+
elif outcome == "incomplete":
218+
# This is the ordinary state for this path: the artifact is an
219+
# integrate-only stage report, so it carries no rehearsal evidence.
220+
# Nothing failed — do not say FAILED.
221+
glyph, label = glyph_warn(), c_warn(
222+
f"integrate ingested (run {label_id}: no rehearsal evidence)"
223+
)
173224
else:
174225
glyph, label = glyph_fail(), c_fail(f"rehearsal ingested (run {label_id}: FAILED)")
175226
elif action in ("cached", "local-fresher"):

heart/dashboard.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,9 +571,18 @@ def build_board(
571571
profile = vr.get("profile") or "?"
572572
stages = vr.get("stages") or {}
573573
meta = f"v{ver} profile={profile} ({vr.get('ts', '?')})"
574-
if ready is False:
574+
# Mirror the readiness gate: read `validation_outcome`, falling back to
575+
# the legacy boolean. `incomplete` is an evidence gap (WARN), not a
576+
# failure (FAIL) — a green integrate-only ingest lands there, and a FAIL
577+
# row beside a stale header verdict reads as a broken release.
578+
outcome = vr.get("validation_outcome")
579+
if outcome not in ("pass", "fail", "incomplete"):
580+
outcome = "fail" if ready is False else ("pass" if ready is True else None)
581+
if outcome == "fail":
575582
st, summary = FAIL, f"NOT release_ready — {meta}"
576-
elif ready is True:
583+
elif outcome == "incomplete":
584+
st, summary = WARN, f"incomplete — no rehearsal evidence — {meta}"
585+
elif outcome == "pass":
577586
st, summary = OK, f"release_ready — {meta}"
578587
else:
579588
st, summary = WARN, f"release_ready unknown — {meta}"
@@ -798,6 +807,10 @@ def to_dict(board: Board) -> dict[str, Any]:
798807
"stale": board.stale,
799808
"red_reasons": board.red_reasons,
800809
"yellow_reasons": board.yellow_reasons,
810+
# Evidence gaps belong on this surface too: the Health Agent and mobile
811+
# read it, and a reason that moves from the red axis to the stale one
812+
# would otherwise vanish from both rather than being re-classified.
813+
"stale_reasons": board.stale_reasons,
801814
"pages_url": PAGES_URL,
802815
"sections": [
803816
{

heart/readiness.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
exceeds the newest released version of its library (UNSATISFIABLE — no
1717
installable release can satisfy it), or an unparseable (BAD) floor/tag; or the
1818
deep install verification last reported ``ready == false``; or the
19-
release-validation report last ingested reports ``release_ready == false`` (a
20-
stage failed).
19+
release-validation report last ingested reports ``validation_outcome ==
20+
"fail"`` (a stage failed, or failing/timed-out counts were recorded). A report
21+
predating that field falls back to ``release_ready == false`` → RED, so the
22+
gate fails closed on evidence it cannot classify.
2123
- **YELLOW** (caution) for soft signals: workspace-validation not passing (the
2224
workspace scripts/notebooks carry standing debt, so this is advisory — never a
2325
hard block), script-timing regressions, stale open PRs, stale parked scripts, a
@@ -30,7 +32,9 @@
3032
- **STALE** (an evidence gap, the freshness tier) when nothing is known-bad but
3133
some evidence is *missing or expired*: a check that was never run, a
3234
passing-but-aged report, a rehearsal whose ``commit_shas`` no longer match
33-
``main``, an unknown repo/version status. The remedy for a stale reason is to
35+
``main``, a validation report whose ``validation_outcome`` is ``incomplete``
36+
(nothing failed; the rehearsal evidence is absent), an unknown repo/version
37+
status. The remedy for a stale reason is to
3438
**re-run the check**, never to fix code — which is exactly what separates it
3539
from yellow. The tier is not a skip lever: evidence whose *last known result
3640
was adverse* stays yellow/red until a fresh run says otherwise; only
@@ -453,13 +457,29 @@ def scope_local(msg: str, key: str) -> None:
453457
# This is the M2 gate: the report proves the exact source about to ship was
454458
# built, published to TestPyPI, installed from the wheel, and exercised at
455459
# release fidelity. Absent/stale/source-not-matching → YELLOW ("no release
456-
# rehearsal for current source"); failing → RED. Pass/fail (release_ready)
457-
# is the RED axis; fidelity+freshness (profile / commit_shas / age) is the
458-
# YELLOW axis — a passing-but-stale report is a caution, not a blocker.
460+
# rehearsal for current source"); failing → RED. `validation_outcome` is the
461+
# RED axis; fidelity+freshness (profile / commit_shas / age) is the YELLOW
462+
# axis — a passing-but-stale report is a caution, not a blocker.
463+
#
464+
# Read `validation_outcome`, NOT `release_ready`: the boolean collapses
465+
# "something failed" and "the rehearsal evidence is missing" into one
466+
# `false`, and the tick's integrate-only auto-ingest always lands on that
467+
# `false` (see heart/validate.py). Grading it RED reported a failure that
468+
# had not happened. A report predating the field carries no discriminator,
469+
# so `false` there stays RED — fail closed.
459470
vr = snapshot.get("validation_report")
460471
if isinstance(vr, dict) and vr:
461472
ready = vr.get("release_ready")
462-
if ready is False:
473+
outcome = vr.get("validation_outcome")
474+
if outcome not in ("pass", "fail", "incomplete"):
475+
outcome = "fail" if ready is False else ("pass" if ready is True else None)
476+
if outcome == "incomplete":
477+
# Nothing is wrong; the rehearsal evidence is simply absent. The
478+
# wording must contain "release validation" — the Health Agent
479+
# classifier matches on that string to route the remedy.
480+
stale.append("release validation incomplete: no rehearsal for current source")
481+
hit("validation_absent")
482+
elif outcome == "fail":
463483
failed_stages = [
464484
n for n, s in (vr.get("stages") or {}).items()
465485
if isinstance(s, dict) and s.get("status") == "fail"
@@ -469,7 +489,7 @@ def scope_local(msg: str, key: str) -> None:
469489
+ (f" (stage {', '.join(failed_stages)})" if failed_stages else "")
470490
)
471491
hit("validation_failed")
472-
elif ready is True:
492+
elif outcome == "pass":
473493
commit_shas = vr.get("commit_shas") or {}
474494
mismatched: list[str] = []
475495
unconfirmed: list[str] = []

0 commit comments

Comments
 (0)