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
23 changes: 22 additions & 1 deletion .github/workflows/hw-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,13 @@ jobs:
# previous PR's evidence files and home (run 33866758629 on #702
# carried #700's fable-summary.md and #686's route outputs) and can
# cite them as its own. Every session starts empty.
rm -rf fable-evidence fable-home
# decision.json is the same hazard, one level worse: upload-artifact
# runs `if: always()`, so a decide phase that dies before writing its
# own file publishes the PREVIOUS run's verdict as this run's. #702's
# decide job failed and the run published #682's decision -- "six
# refused loads ... master says 'no model loaded'" -- blocking a PR
# whose own lanes were 8/8 pass.
rm -rf fable-evidence fable-home decision.json
mkdir -p fable-evidence fable-home
exec 9>/home/kaden/actions-runner/_cache/hw-gate-gpu.lock
flock --exclusive --timeout 3600 9
Expand Down Expand Up @@ -575,6 +581,21 @@ jobs:
# floor-applied verdict under `.decision_final`; `.decision` is a JSON
# object, so the old jq yielded "[object]" and every run — including
# a successful merge-staging (#689, run 33889229683) — went red.
# A decision is only this PR's if it names this PR's head. The runner
# workspace is reused and upload-artifact runs `if: always()`, so a
# decide phase that dies can publish the previous run's verdict:
# #702 was blocked on #682's decision while its own lanes were 8/8
# pass. An artifact from another commit is a gate malfunction, not a
# verdict, so it fails as one rather than being obeyed.
want_head="${{ needs.select.outputs.head_sha }}"
got_head=$(jq -r '.head // ""' decision/decision.json)
if [ -n "$got_head" ] && [ "$got_head" != "$want_head" ]; then
echo "::error::decision artifact is for $got_head but this run is $want_head — stale decision.json from a reused workspace; re-run the gate"
exit 1
fi
if [ -z "$got_head" ]; then
echo "::warning::decision artifact predates head attribution; cannot verify it belongs to $want_head"
fi
decision=$(jq -r '.decision_final // .decision.decision // "hold"' decision/decision.json)
hard=$(jq -r '(.floor.hard // []) | join(", ")' decision/decision.json)
echo "decision=$decision hard_floor=[$hard] human_reviewed=${{ needs.select.outputs.human_reviewed }}"
Expand Down
11 changes: 11 additions & 0 deletions scripts/hw-gate/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -1690,6 +1690,17 @@ def _has_hold(r: str) -> bool:
"version": 1,
"seat": "fable",
"model": model,
# The decision must be attributable to the commit it judged. The
# runner workspace is reused, so a decide phase that dies before
# writing its own decision.json leaves the PREVIOUS run's file in
# place, and `upload-artifact: if: always()` publishes it as this
# run's decision. That happened on 2026-09-04: #702's decide job
# failed and the run published #682's verdict -- "six refused loads
# ... master says 'no model loaded'" -- blocking a PR whose own lanes
# were 8/8 pass. The status job cross-checks these against the run's
# head, so a stale artifact is caught instead of obeyed.
"base": args.base,
"head": args.head,
"decision": decision,
"floor": {"hard": hard, "soft": soft},
"decision_final": decision_final,
Expand Down
23 changes: 23 additions & 0 deletions scripts/hw-gate/tests/test_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,3 +874,26 @@ class R: returncode, stdout, stderr = 0, "", ""
assert "--thinking" in cmd and cmd[cmd.index("--thinking") + 1] == "xhigh"
assert cmd[cmd.index("--max-time") + 1] == f"{minutes}m"
assert "GH_TOKEN" not in captured["env"] and captured["env"]["HW_GATE_DEVICES"] == "0,1,2,3,4"


def test_decision_records_the_commit_it_judged():
"""A decision is only usable if it names the commit it judged.

The runner workspace is reused and upload-artifact runs `if: always()`, so a
decide phase that dies publishes the previous run's decision.json as this
run's. On 2026-09-04 #702 was blocked by #682's verdict -- announcement and
all -- while #702's own lanes were 8/8 pass. The status job cross-checks
`.head`, which only works if review.py records it.
"""
tmp = Path(tempfile.mkdtemp())
result, out_path, gh_log, gh_comments, omp_log, base, head, checkout = _run_decide(
tmp, sol_final="greenlight",
fable_response={"phase": "decide", "decision": "merge-staging", "agrees_with_sol": True,
"override": None, "regressions": [], "further_evidence_wanted": [],
"rationale": "r", "announcement": "a"},
)
data = json.loads(out_path.read_text())
assert data["head"] == head, data.get("head")
assert data["base"] == base, data.get("base")


Loading