From f1909c287d2756882ced765a9b2f852b6dbc3981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:44:20 +0000 Subject: [PATCH] fix(cloud_validation): match rehearsals by completion time, not a skewed created_at On 2026-11-01 GitHub reported PyAutoHands rehearsal run 41290559836 as created 09:50:55Z yet updated 09:09:27Z (every start stamp an hour late). collect() prefiltered candidates on `created > integrate start`, so the valid rehearsal for v2026.11.1.1.dev85301 was discarded and the board went STALE ("no rehearsal for current source") although integrate 41291876649 passed. Use the earlier of created_at and updated_at as the producer's start (a run cannot finish before it was created), both for the prefilter and for the report timestamp. Identity checks and matching_rehearsal's `produced <= integrate start` guard are unchanged, so a rehearsal that genuinely finished after the integrate started is still rejected. Closes #321 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016nhQ8QAki1rcqoS951ubcu --- heart/checks/cloud_validation.py | 20 +++++++++++++++++++- tests/test_cloud_validation.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/heart/checks/cloud_validation.py b/heart/checks/cloud_validation.py index 2d90f40..da4a2ba 100644 --- a/heart/checks/cloud_validation.py +++ b/heart/checks/cloud_validation.py @@ -82,6 +82,24 @@ def matching_rehearsal( ) +def _effective_created(run: dict): + """When a producer run started, robust to a skewed ``created_at``. + + A run cannot finish before it was created, so ``created_at`` later than + ``updated_at`` is a bad start stamp, not a late run: on 2026-11-01 GitHub + reported PyAutoHands run 41290559836 as created 09:50:55Z yet updated + 09:09:27Z (every job start stamp an hour late), and the valid rehearsal + was discarded. Take the earlier of the two; ``None`` when either is + missing, so an unstamped run is still skipped. Ordering against the + integration remains ``matching_rehearsal``'s ``produced <= start`` check. + """ + created = release_run._parse_ts(run.get("created_at")) + updated = release_run._parse_ts(run.get("updated_at")) + if not created or not updated: + return None + return min(created, updated) + + def collect(config: dict, fetch=api, get_artifact=download) -> dict[str, Any]: """Observe the newest integration only; never fall back to an older pass. @@ -136,7 +154,7 @@ def collect(config: dict, fetch=api, get_artifact=download) -> dict[str, Any]: f"repos/{producer_repo}/actions/workflows/release.yml/runs?branch=main&per_page=20" ).get("workflow_runs", []) for candidate in candidates[:20]: - created = release_run._parse_ts(candidate.get("created_at")) + created = _effective_created(candidate) if ( not created or created > observed diff --git a/tests/test_cloud_validation.py b/tests/test_cloud_validation.py index d4f0796..005e6c6 100644 --- a/tests/test_cloud_validation.py +++ b/tests/test_cloud_validation.py @@ -135,6 +135,27 @@ def test_failed_producers_override_pass(evidence, which, conclusion): assert run(evidence)["validation_outcome"] == "fail" +def test_skewed_start_stamp_does_not_hide_a_finished_rehearsal(evidence): + # PyAutoHands run 41290559836 (2026-11-01): created_at an hour late, after + # its own updated_at, while the run really finished before the integrate. + evidence["producer"]["created_at"] = "2026-10-01T12:40:00Z" + evidence["producer"]["updated_at"] = "2026-10-01T11:59:51Z" + result = run(evidence) + assert result["rehearsal_matched"] + assert result["validation_outcome"] == "pass" + # The skewed stamp must not date the evidence after the integrate start. + assert report(evidence)["ts"] <= "2026-10-01T12:00:00" + assert "rehearse" in report(evidence)["stages"] + + +def test_skewed_start_stamp_cannot_admit_a_later_rehearsal(evidence): + evidence["producer"]["created_at"] = "2026-10-01T12:40:00Z" + evidence["producer"]["updated_at"] = "2026-10-01T12:20:00Z" + result = run(evidence) + assert not result["rehearsal_matched"] + assert result["validation_outcome"] == "incomplete" + + def test_missing_rehearsal_is_incomplete(evidence): evidence["artifact"] = None assert run(evidence)["validation_outcome"] == "incomplete"