|
| 1 | +"""Refine and recheck: what happens to a change the quality gate rejected. |
| 2 | +
|
| 3 | +A change the gate rejects is never merged (design v8, D12). For the service's |
| 4 | +own change sets the generator gets the rejection reasons, proposes a refined |
| 5 | +change on current main, and the WHOLE gate runs again (L1, L2 per block, |
| 6 | +consistency; nothing from the rejected judgement is reused). At most |
| 7 | +``MAX_REFINES`` refinements per original change; after that it stays unmerged |
| 8 | +and goes to people with every round's reasons. |
| 9 | +
|
| 10 | +Triggers: a change set the gate failed when it was staged (``failed`` with a |
| 11 | +``fail`` decision), and a PR the publisher's local gate refused because of the |
| 12 | +change itself (``refine_needed``). A refinement of a change that already has a |
| 13 | +PR replaces it the way a rebuild does: the old PR waits in ``superseding`` |
| 14 | +until its close is observed, the refined change gets a PR of its own. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import uuid |
| 20 | + |
| 21 | +MAX_REFINES = 2 |
| 22 | +REFINE_MARK = "refine of " |
| 23 | +REFINABLE = ("intake", "rebuild", "refine") |
| 24 | + |
| 25 | + |
| 26 | +def reasons_of(changeset: dict) -> list[str]: |
| 27 | + """Everything the gate said against this change, most specific first.""" |
| 28 | + detail = changeset["detail"] |
| 29 | + reasons = [str(p) for p in detail.get("gate_problems") or []] |
| 30 | + decision = detail.get("decision") or {} |
| 31 | + for issue in decision.get("l1_issues") or []: |
| 32 | + reasons.append(f"L1 {issue.get('code')} {issue.get('path', '')} {issue.get('detail', '')}".strip()) |
| 33 | + for block in decision.get("blocks") or []: |
| 34 | + if block.get("verdict") == "fail": |
| 35 | + why = "; ".join(f"{k}: {v}" for k, v in (block.get("reasons") or {}).items()) |
| 36 | + reasons.append(f"L2 rejected {block.get('rule_id') or block.get('path')}: {why}".strip()) |
| 37 | + for item in decision.get("consistency") or []: |
| 38 | + if item.get("verdict") == "conflict": |
| 39 | + reasons.append(f"consistency conflict in {item.get('owner_dir')}: {item.get('conflicts')}") |
| 40 | + reasons += [str(r) for r in decision.get("reasons") or []] |
| 41 | + return list(dict.fromkeys(r for r in reasons if r))[:30] |
| 42 | + |
| 43 | + |
| 44 | +def needs_refinement(changeset: dict) -> bool: |
| 45 | + detail = changeset["detail"] |
| 46 | + if changeset["kind"] not in REFINABLE or detail.get("refined_as"): |
| 47 | + return False |
| 48 | + if changeset["status"] == "refine_needed": |
| 49 | + return True |
| 50 | + return changeset["status"] == "failed" and (detail.get("decision") or {}).get("status") == "fail" |
| 51 | + |
| 52 | + |
| 53 | +def _refine_of(rt, repo: str, old_id: str) -> str | None: |
| 54 | + """A refinement already staged for ``old_id`` (resumed, never staged twice), |
| 55 | + in whatever status staging left it (companion_pending included).""" |
| 56 | + for candidate in rt.ledger.changesets_of_kind(repo, "refine"): |
| 57 | + try: |
| 58 | + evidence = rt.load_changeset_files(candidate["id"]).get("evidence") or [] |
| 59 | + except (OSError, ValueError): |
| 60 | + continue |
| 61 | + if any(str(item.get("source_reference") or "") == REFINE_MARK + old_id for item in evidence): |
| 62 | + return candidate["id"] |
| 63 | + return None |
| 64 | + |
| 65 | + |
| 66 | +def refine(rt, lifecycle, changeset: dict) -> str | None: |
| 67 | + """Refine one rejected change set; returns the refined change set's id or |
| 68 | + None (sent to people). Needs the scheduler's lease.""" |
| 69 | + from ..knowledge_service.lifecycle import LifecycleError |
| 70 | + from ..trace_store import accepted_key, trace_context |
| 71 | + from .intake import MAX_OPS_PER_EVENT, draft_changes |
| 72 | + from .merge import _outcome, _request_close |
| 73 | + from .models import ModelUnavailable |
| 74 | + from .runtime import gate_and_stage, publish |
| 75 | + |
| 76 | + owner = getattr(rt, "lease_owner", None) |
| 77 | + if not owner: |
| 78 | + return None |
| 79 | + detail = changeset["detail"] |
| 80 | + reasons = reasons_of(changeset) or ["the quality gate rejected the change"] |
| 81 | + round_ = int(detail.get("refine_round") or 0) + 1 |
| 82 | + history = [*(detail.get("refine_history") or []), {"changeset": changeset["id"], "reasons": reasons}] |
| 83 | + |
| 84 | + def to_people(why: str) -> None: |
| 85 | + rt.ledger.update_changeset(changeset["id"], status="refine_exhausted", |
| 86 | + detail={**detail, "refine_history": history}) |
| 87 | + lines = [f"round {i}: {h['changeset']}: " + "; ".join(h["reasons"])[:300] for i, h in enumerate(history)] |
| 88 | + rt.ledger.enqueue_human(lifecycle.repo, f"{why}; not merged. " + " | ".join(lines)[:1800], changeset["id"]) |
| 89 | + _outcome(rt, changeset, "refine_exhausted", rounds=len(history)) |
| 90 | + |
| 91 | + if round_ > MAX_REFINES: |
| 92 | + to_people(f"still rejected after {MAX_REFINES} refinements") |
| 93 | + return None |
| 94 | + new_id = _refine_of(rt, lifecycle.repo, changeset["id"]) |
| 95 | + if new_id is None: |
| 96 | + base_sha = rt.knowledge.fetch() |
| 97 | + base = rt.knowledge.knowledge_files(base_sha) |
| 98 | + external = rt.knowledge.external_texts(base_sha) |
| 99 | + evidence = rt.load_changeset_files(changeset["id"]).get("evidence") or [] |
| 100 | + release, today = rt.release_for(lifecycle.repo), rt.today() |
| 101 | + # ALL the evidence: an intake change set can combine several events, |
| 102 | + # and a refinement must not silently drop the rules of later ones |
| 103 | + prompt_evidence = {"title": "; ".join(str(e.get("title") or "") for e in evidence if e.get("title")), |
| 104 | + "body": "\n\n".join(str(e.get("body") or "") for e in evidence if e.get("body")), |
| 105 | + "changed_files": sorted({f for e in evidence for f in e.get("changed_files") or []}), |
| 106 | + "evidence": evidence, |
| 107 | + "previous_operations": detail.get("operations") or [], |
| 108 | + "gate_feedback": reasons, |
| 109 | + "instruction": "The quality gate rejected the previous operations for the reasons in " |
| 110 | + "gate_feedback. Propose corrected operations that fix exactly those " |
| 111 | + "problems, or none if the change should not be made."} |
| 112 | + key, holder = f"refine:{lifecycle.repo}:{changeset['id']}:{uuid.uuid4().hex[:8]}", {} |
| 113 | + try: |
| 114 | + with trace_context(draft_key=key, _accepted=holder, step="refine", refine_of=changeset["id"]): |
| 115 | + # the whole change set is refined at once: as many operations as |
| 116 | + # the events it combines may carry |
| 117 | + limit = max(MAX_OPS_PER_EVENT * max(1, len(evidence)), len(detail.get("operations") or [])) |
| 118 | + draft = draft_changes(repo=lifecycle.repo, repo_dir=lifecycle.knowledge_dir, event_id=0, |
| 119 | + evidence=prompt_evidence, files=base, gateway=rt.gateway, |
| 120 | + generator=rt.generator, release=release, today=today, |
| 121 | + max_operations=limit) |
| 122 | + except ModelUnavailable: |
| 123 | + return None # the pinned generator is unavailable: tried again next pass |
| 124 | + except LifecycleError as exc: |
| 125 | + to_people(f"refinement could not be drafted: {exc}") |
| 126 | + return None |
| 127 | + if draft.rejected or not draft.operations or draft.result is None: |
| 128 | + to_people("the generator proposed no valid refinement") |
| 129 | + return None |
| 130 | + mark = {"source_reference": REFINE_MARK + changeset["id"], "title": "; ".join(reasons)[:300]} |
| 131 | + new_id = gate_and_stage(rt, lifecycle, owner, kind="refine", base=base, base_sha=base_sha, |
| 132 | + external=external, operations=draft.operations, result=draft.result, |
| 133 | + evidence=[*evidence, mark], event_ids=[], release=release, |
| 134 | + draft_keys=[accepted_key(key, holder)] if accepted_key(key, holder) else [], |
| 135 | + extra_detail={"refine_round": round_, "refine_history": history}) |
| 136 | + open_pr = changeset.get("pr_number") is not None |
| 137 | + rt.ledger.update_changeset(changeset["id"], status="superseding" if open_pr else "refined", |
| 138 | + detail={**detail, "refined_as": new_id, "refine_history": history, |
| 139 | + "superseded_because": "refined after the quality gate rejected it"}) |
| 140 | + _outcome(rt, changeset, "refined", replaced_by=new_id, round=round_) |
| 141 | + if open_pr: |
| 142 | + _request_close(rt, lifecycle.repo, rt.ledger.changeset(changeset["id"])) |
| 143 | + if rt.ledger.changeset(new_id)["status"] == "gated": |
| 144 | + publish(rt, lifecycle, new_id) |
| 145 | + return new_id |
| 146 | + |
| 147 | + |
| 148 | +def refine_pending(rt, lifecycle) -> list[str]: |
| 149 | + """One pass: refine every rejected change set of this repository.""" |
| 150 | + events: list[str] = [] |
| 151 | + if not getattr(rt, "lease_owner", None): |
| 152 | + return events |
| 153 | + for changeset in rt.ledger.changesets(lifecycle.repo, ("failed", "refine_needed")): |
| 154 | + if not needs_refinement(changeset): |
| 155 | + continue |
| 156 | + new_id = refine(rt, lifecycle, changeset) |
| 157 | + events.append(f"refined {changeset['id']} as {new_id}" if new_id else f"refine_stopped {changeset['id']}") |
| 158 | + return events |
0 commit comments