Skip to content

Commit a36d703

Browse files
tzhouamclaude
andcommitted
Refine and recheck knowledge changes the gate rejected (design v8)
A change the quality gate rejects is never merged. The service's own changes are now refined with the rejection reasons and judged again, instead of going straight to people. - refine: a change set of kind intake, rebuild or refine is refined in two cases: it failed its gate when staged (status failed with a fail decision), or the publisher's local gate refused it because of the change itself (new status refine_needed, set by the merge ack). - Under the scheduler's lease, and only while the repository is not paused, the generator gets every reason with the original operations: local-gate problems, L1 issues, per-dimension L2 reasons, consistency conflicts and decision reasons. - It proposes a refined change on current main (kind refine, with refine_round and refine_history), and the WHOLE gate runs again. - The original becomes refined, or superseding (its PR closed) when a PR was already open, like a rebuild. A passing refinement is published. - At most 2 refinements. After that the change is refine_exhausted: one human-queue entry lists every round's reasons, and no further generator calls are made. - A refinement is found again by its evidence mark in any status (companion_pending included; new Ledger.changesets_of_kind), so a crash never stages it twice. - The generator gets ALL the original evidence, since an intake change set can combine several events. - A rebuild carries the refine lineage (refine_round/refine_history), so the limit never resets because context moved. - A refusal caused by how the repository is set up (main must allow a direct merge) goes to people: refining cannot fix it and would only loop. External PRs refused by the local gate stay gate_failed for people; their daily recheck is the next change. - external._ours now finds the service's own PRs in ANY status (new Ledger.changesets_for_pr), so a PR in refine_needed or refine_exhausted is never picked up again as someone else's PR. - draft_changes takes max_operations: a refinement covers the whole change set, up to 6 operations per combined event, and at least as many as the original change had. - gate_and_stage accepts extra_detail. Tests: a rejected intake is refined with the reasons and passes; a change still rejected after two refinements stays unmerged for people with no further generator calls; a PR the local gate refused is replaced by a refined one; a refinement is never staged twice; an external refusal and a setup refusal go to people; all the evidence reaches the refinement; a rebuild keeps the refine limit; a refinement staged before a crash is found in any status; our own PRs are never judged as external in any status; a refinement can carry 7+ operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1424d4a commit a36d703

12 files changed

Lines changed: 463 additions & 24 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,14 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
218218
拒绝(转人工,要求 `main` 允许发布器直接合并);只有经 GraphQL 确认既不在合并队列中(必要时 `dequeuePullRequest`)也没有自动合并,才记为拒绝,否则保留意图,由恢复流程继续取消或接收
219219
后来发生的合并。存在未结的合并意图时该项跳过,直到恢复流程从 GitHub 得到结果。
220220
- external 的状态集合由 `merge.IN_FLIGHT` 派生,合并中的外部 PR 不会被重复评审。
221+
222+
## 2026-09-29 v8:精炼—复检
223+
`refine`:质量门拒绝的服务自身变更(`intake`/`rebuild`/`refine` 类,暂存时 `failed` 且判定为 `fail`,或发布器本地门禁因变更本身
224+
拒绝 → `refine_needed`)从不合并。调度器(持有租约、仓库未暂停)把全部拒绝理由(本地门禁问题、L1 问题、L2 各维度理由、
225+
一致性冲突、判定理由)连同原操作交给生成器,在当前 main 上生成精炼后的变更集(kind `refine`,`refine_round`、`refine_history`),
226+
整体重跑 L1 + L2 + 一致性;通过则发布,原变更记为 `refined`(有 PR 则 `superseding` 并关闭旧 PR)。至多 `MAX_REFINES = 2`
227+
轮;仍失败 → `refine_exhausted`,人工队列一条,附每一轮理由,之后不再调用生成器。精炼按证据标记恢复(任意状态,含 `companion_pending`),崩溃后不会重复暂存;生成器拿到全部原始证据;重建沿用精炼轮次,
228+
上限不会因上下文变化而重置;精炼一次处理整个变更集,操作数上限为 6 × 事件数(且不少于原操作数)。
229+
服务自己的 PR 在任何状态下(`Ledger.changesets_for_pr`)都不会被当作他人的 PR 重新评审。
230+
外部 PR 被本地门禁拒绝仍为 `gate_failed` 转人工(他人 PR 的处理见后续);仓库设置导致的拒绝(`main` 必须允许发布器直接合并)
231+
也转人工,精炼无法修复。

‎doc/features/knowledge-service.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ infermatrix-copilot kb rollback --to <sha> # 回指到较早的快照(下一
6363

6464
合并流程(v8):`open_pr` 回执 → 为该 PR 的精确 head 签发判定,随 `merge` 项交给 GPU 盒发布器 → 发布器在
6565
`main` 与 PR head 的合并树上运行本地门禁,通过才 `gh pr merge`(每仓库同时至多 1 个)→ 合并后激活新快照。
66-
本地门禁拒绝的变更从不合并:上下文变化则在当前 main 上重建,变更本身的问题转人工。发版巡检在上游新 release 后运行
66+
本地门禁拒绝的变更从不合并:上下文变化则在当前 main 上重建;服务自己的变更被质量门或本地门禁拒绝时,由生成器按
67+
理由精炼后整体复检,至多 2 轮,仍失败则留在人工队列(不合并)。发版巡检在上游新 release 后运行
6768
T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。
6869

6970
评审服务读取知识:将 `KNOWLEDGE_ROOT` 指向 `$KB_STATE_DIR/active`。

‎src/infermatrix_copilot/kb_service/external.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,11 @@ def _judged(rt, number: int, head: str, path: str) -> bool:
141141

142142

143143
def _ours(rt, number: int) -> bool:
144+
"""A PR the service opened, in ANY status (refine_needed, refine_exhausted,
145+
superseding...): it is never judged again as someone else's PR."""
144146
for lifecycle in rt.registry.values():
145-
for changeset in rt.ledger.changesets(lifecycle.repo, _ALL):
146-
if changeset["pr_number"] == number and changeset["kind"] != KIND:
147-
return True
147+
if any(cs["kind"] != KIND for cs in rt.ledger.changesets_for_pr(lifecycle.repo, number)):
148+
return True
148149
return False
149150

150151

‎src/infermatrix_copilot/kb_service/intake.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ def draft_prompt(repo: str, evidence: dict, files: dict[str, str], repo_dir: str
143143
"reason", "evidence", "page_title")
144144

145145

146-
def _validate_reply(data: dict) -> None:
146+
def _validate_reply(data: dict, max_operations: int = MAX_OPS_PER_EVENT) -> None:
147147
operations = data.get("operations")
148-
if not isinstance(operations, list) or len(operations) > MAX_OPS_PER_EVENT:
149-
raise ValueError(f"operations must be a list of at most {MAX_OPS_PER_EVENT}")
148+
if not isinstance(operations, list) or len(operations) > max_operations:
149+
raise ValueError(f"operations must be a list of at most {max_operations}")
150150
for item in operations:
151151
if not isinstance(item, dict) or item.get("kind") not in INTAKE_KINDS:
152152
raise ValueError(f"operation kind must be one of {INTAKE_KINDS}")
@@ -162,7 +162,7 @@ def _validate_reply(data: dict) -> None:
162162

163163
def draft_changes(*, repo: str, repo_dir: str, event_id: int, evidence: dict,
164164
files: dict[str, str], gateway: ModelGateway, generator: ModelRole,
165-
release: str, today: str) -> Draft:
165+
release: str, today: str, max_operations: int = MAX_OPS_PER_EVENT) -> Draft:
166166
prompt = draft_prompt(repo, evidence, files, repo_dir)
167167
attempts: list[dict] = []
168168
feedback = ""
@@ -172,7 +172,7 @@ def draft_changes(*, repo: str, repo_dir: str, event_id: int, evidence: dict,
172172
try:
173173
with trace_context(attempt=attempt):
174174
reply = gateway.call_json(generator, system=SYSTEM, prompt=prompt + feedback,
175-
validate=_validate_reply)
175+
validate=lambda data: _validate_reply(data, max_operations))
176176
except ModelUnavailable as exc:
177177
if "failed its schema" not in str(exc):
178178
raise # the model itself is unavailable: the event waits

‎src/infermatrix_copilot/kb_service/ledger.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,18 @@ def changesets(self, repo: str, statuses: tuple[str, ...]) -> list[dict[str, Any
400400
(repo, *statuses)).fetchall()
401401
return [_changeset_row(r) for r in rows]
402402

403+
def changesets_for_pr(self, repo: str, number: int) -> list[dict[str, Any]]:
404+
"""Every change set of ``repo`` that has PR ``number``, whatever its status."""
405+
rows = self._conn.execute(
406+
"SELECT * FROM changesets WHERE repo=? AND pr_number=? ORDER BY created_at", (repo, number)).fetchall()
407+
return [_changeset_row(r) for r in rows]
408+
409+
def changesets_of_kind(self, repo: str, kind: str) -> list[dict[str, Any]]:
410+
"""Every change set of ``kind`` in ``repo``, whatever its status."""
411+
rows = self._conn.execute(
412+
"SELECT * FROM changesets WHERE repo=? AND kind=? ORDER BY created_at", (repo, kind)).fetchall()
413+
return [_changeset_row(r) for r in rows]
414+
403415
def record_verdict(self, repo: str, *, layer: str, verdict: str, changeset_id: str | None = None,
404416
block_id: str = "", model: str = "", model_version: str = "",
405417
detail: dict | None = None) -> None:

‎src/infermatrix_copilot/kb_service/merge.py‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
RESIGN_GATE = ("outside its issue window", "no valid signed verdict")
4949
REBUILD_GATE = ("context changed since the verdict", "pages changed since the consistency judgement",
5050
"does not merge cleanly", "signed patch manifest does not match")
51+
# a refused merge caused by how the repository is set up, not by the change:
52+
# refining cannot fix it, people must (it would only loop)
53+
CONFIG_MERGE = ("must allow a direct merge",)
5154
# a refused local-gate merge that is about the moment, not the change: sign again
5255
TRANSIENT_MERGE = ("main kept moving", "not merging:", "head of PR", "head moved", "git ", "gh ",
5356
"did not merge", "is not open", "verdict: ")
@@ -242,11 +245,19 @@ def _apply_merge_ack(rt, changeset: dict, ack: dict) -> None:
242245
elif any(m in text for m in TRANSIENT_MERGE) and not ack.get("problems"):
243246
rt.ledger.update_changeset(changeset_id, status="pr_open", pending_item=None)
244247
else: # the local gate refused the change itself: it is not merged
245-
rt.ledger.update_changeset(changeset_id, status="gate_failed", pending_item=None,
246-
detail={**changeset["detail"], "gate_problems": (ack.get("problems") or [])[:50]})
247-
rt.ledger.enqueue_human(repo, f"local gate refused PR #{changeset['pr_number']}: {text[:400]}",
248-
changeset_id)
249-
_outcome(rt, changeset, "gate_failed", problems=(ack.get("problems") or [])[:20])
248+
from .refine import REFINABLE
249+
250+
problems = (ack.get("problems") or [])[:50]
251+
if changeset["kind"] in REFINABLE and not any(m in text for m in CONFIG_MERGE):
252+
# our own change: refined with these reasons and checked again
253+
rt.ledger.update_changeset(changeset_id, status="refine_needed", pending_item=None,
254+
detail={**changeset["detail"], "gate_problems": problems})
255+
else:
256+
rt.ledger.update_changeset(changeset_id, status="gate_failed", pending_item=None,
257+
detail={**changeset["detail"], "gate_problems": problems})
258+
rt.ledger.enqueue_human(repo, f"local gate refused PR #{changeset['pr_number']}: {text[:400]}",
259+
changeset_id)
260+
_outcome(rt, changeset, "gate_failed", problems=problems[:20])
250261

251262

252263
def sign_verdict(rt, changeset: dict) -> dict:
@@ -405,7 +416,11 @@ def to_people(reason: str) -> None:
405416
new_id = gate_and_stage(rt, lifecycle, owner, kind="rebuild", base=base, base_sha=base_sha,
406417
external=external, operations=operations, result=result,
407418
evidence=evidence, event_ids=[], release=detail["release"],
408-
draft_keys=[])
419+
draft_keys=[],
420+
# a rebuild continues the change's refine lineage: the
421+
# limit never resets because context moved meanwhile
422+
extra_detail={k: detail[k] for k in ("refine_round", "refine_history")
423+
if k in detail})
409424
staged = rt.ledger.changeset(new_id)
410425
if staged["status"] not in ("gated", *IN_FLIGHT):
411426
# the rebuild itself did not pass the gate: nothing replaces the PR
@@ -594,6 +609,10 @@ def advance(rt, lifecycle) -> list[str]:
594609
**current["detail"], "verdict_issued_at": envelope["payload"]["issued_at"]})
595610
merging = [changeset]
596611
events.append(f"merge issued {changeset['id']}")
612+
from .refine import refine_pending
613+
614+
if not paused:
615+
events += refine_pending(rt, lifecycle)
597616
return events + verify_merged(rt, lifecycle)
598617

599618

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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

Comments
 (0)