Skip to content

Commit 85b6bbd

Browse files
tzhouamclaude
andcommitted
Draft companion PRs for citations outside knowledge/ before a retirement lands
When a knowledge change retires, replaces or purges rules that files outside knowledge/ still cite, the design has the service draft a companion PR that updates those citations. The knowledge change waits (its rules stay active, so nothing ever cites a retired rule) and resumes automatically once the companion merges. Until now such changes just went to people. - companion.stage_companion runs when the gate's only problem is external citations (not for held sweep change sets or forced-human ones). Each citing file is rewritten: a replaced rule's ID becomes its replacement; a list or table entry citing a retired rule is dropped; any other mention loses the ID (indentation kept, so nested YAML stays valid). Only the companion whitelist (skills, plugins, adapters, doc, playbooks) is touched. The knowledge change set waits as companion_pending. - The companion goes out through open_companion_pr as a draft labelled kb:companion, and into the human queue. The publisher refuses any path outside the companion whitelist (knowledge/, .github/, src/, tools/) and refuses post_verdict or enqueue on a companion, so only people can ready and merge it. Companions are recognized by their branch as well as the label, so a label that failed to apply cannot open that door (fail closed). A companion staged but not handed over (a crash) is published by the scheduler's recovery pass. - The scheduler follows the companion. On merge it rebuilds the waiting change on current main (the citations are gone, so it passes the gate and is published; a change set that never had a PR is superseded directly). The companion is marked merged only once every waiting change was handled, so a failed fetch or a crash retries the rebuild next pass. The rebuild waits while the repository (or everything) is paused. The change a companion belongs to comes from the companion itself (for_changeset), so a crash between staging the companion and linking the change still rebuilds it on merge. If the companion is closed unmerged, the change goes to people. Companion closures do not count toward the overturn breaker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2947d8e commit 85b6bbd

7 files changed

Lines changed: 429 additions & 10 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,11 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
159159
哈希)显式记录而非依赖时间戳:blob 不可变只归档一次,增长的记录文件再次归档,归档期间才落盘的 blob 进入下一个归档,并写 `.sha256` 清单(首行为包的哈希,其后每个成员一行;清单最后写入,
160160
标志归档完整)。发布器(GPU 盒,经已有的 SSH 通道)每轮拉取本地没有的归档,只有哈希校验通过才保存到
161161
`$KB_PUBLISHER_STATE/archive/`,否则记入 trace 并拒绝。恢复:按名称顺序解开到空目录后运行 `TraceStore.rebuild_index`。
162+
163+
## 2026-09-29 配套 PR(外部引用)
164+
质量门唯一的问题是"知识目录外引用了本次退役/替换/删除的规则"时(非巡检暂存、非强制人工),`companion.stage_companion`
165+
自动起草配套变更:被替换规则的 ID 改为新 ID;引用已退役规则的列表项/表格行删除,其余提及去掉 ID;只改配套白名单
166+
(`skills/`、`plugins/`、`adapters/`、`doc/`、`playbooks/`)。配套 PR 经 `open_companion_pr` 以 draft 创建并打 `kb:companion`
167+
标签,进入人工队列;发布器拒绝在其上执行 `post_verdict`/`enqueue`(ready 与合并只能由人完成),且拒绝白名单外路径
168+
(`knowledge/`、`.github/`、`src/`、`tools/`)。知识变更集处于 `companion_pending`(规则保持 active);配套 PR 合并后,调度器
169+
在当前 main 上重建它(外部引用已清零 → 正常过门与发布);配套 PR 被关闭则知识变更转人工,且不计入熔断。
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Companion PRs: update references outside knowledge/ before a retirement lands.
2+
3+
When a knowledge change retires, replaces or purges rules that files outside
4+
``knowledge/`` still cite (skills, plugins, adapters, docs, playbooks), the
5+
gate sends it to people with "references outside knowledge/ need a companion
6+
PR". Instead of stopping there, the service drafts that companion PR itself:
7+
each citing file is rewritten (a replaced rule's ID becomes its replacement;
8+
a list or table entry citing a retired rule is dropped; any other mention of
9+
it is removed). The companion is opened ONLY as a draft labelled
10+
``kb:companion`` through the restricted ``open_companion_pr`` path; people
11+
review, ready and merge it. The knowledge change waits as
12+
``companion_pending`` (its rules stay active meanwhile, so nothing ever cites
13+
a retired rule), and is rebuilt on current main once the companion merged.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import re
19+
20+
COMPANION_REASON = "references outside knowledge/ need a companion PR"
21+
COMPANION_DIRS = ("skills/", "plugins/", "adapters/", "doc/", "playbooks/")
22+
23+
24+
def needs_companion(decision) -> bool:
25+
"""The change is otherwise fine: its only problem is external citations."""
26+
return (decision.status == "human" and len(decision.reasons) == 1
27+
and decision.reasons[0].startswith(COMPANION_REASON) and bool(decision.l1.external_refs))
28+
29+
30+
def rewrite(text: str, rule_id: str, replacement: str | None) -> str:
31+
token = re.compile(rf"(?<![\w-]){re.escape(rule_id)}(?![\w-])")
32+
if replacement:
33+
return token.sub(replacement, text)
34+
out = []
35+
for line in text.splitlines(keepends=True):
36+
if token.search(line):
37+
if line.lstrip().startswith(("- ", "* ", "|")):
38+
continue # a list or table entry about the retired rule
39+
indent = line[:len(line) - len(line.lstrip(" \t"))] # YAML nesting must survive
40+
line = indent + re.sub(r"[ \t]{2,}", " ", token.sub("", line[len(indent):]))
41+
out.append(line)
42+
return "".join(out)
43+
44+
45+
def companion_files(external: dict[str, str], refs, operations) -> dict[str, str]:
46+
replacements = {op.rule_id: op.new_rule_id for op in operations if op.kind == "replace" and op.new_rule_id}
47+
files: dict[str, str] = {}
48+
for rule_id, path in refs:
49+
if not path.startswith(COMPANION_DIRS):
50+
continue # never outside the companion whitelist
51+
text = files.get(path, external[path])
52+
files[path] = rewrite(text, rule_id, replacements.get(rule_id))
53+
return {path: text for path, text in files.items() if text != external[path]}
54+
55+
56+
def stage_companion(rt, lifecycle, owner: str, changeset_id: str, operations, decision,
57+
external: dict[str, str], base_sha: str) -> str | None:
58+
"""Stage (and hand over) the companion for ``changeset_id``; the knowledge
59+
change set then waits as companion_pending. None when nothing can be
60+
rewritten automatically (it stays with people)."""
61+
files = companion_files(external, decision.l1.external_refs, operations)
62+
if not files:
63+
return None
64+
companion_id = rt.ledger.new_changeset_id(lifecycle.repo, "companion")
65+
rt.save_changeset_files(companion_id, {"base_sha": base_sha, "files": files, "deleted": [], "evidence": []})
66+
rt.ledger.stage_intake(owner, lifecycle.repo, companion_id, kind="companion", status="companion_staged",
67+
verdicts=[], human_reason=f"review and merge the companion PR for {changeset_id}",
68+
drafted_events=[], detail={"for_changeset": changeset_id, "base_sha": base_sha,
69+
"refs": [list(r) for r in decision.l1.external_refs]})
70+
waiting = rt.ledger.changeset(changeset_id)
71+
rt.ledger.update_changeset(changeset_id, status="companion_pending",
72+
detail={**waiting["detail"], "companion": companion_id})
73+
publish_companion(rt, lifecycle, companion_id)
74+
return companion_id
75+
76+
77+
def publish_companion(rt, lifecycle, companion_id: str) -> str:
78+
changeset = rt.ledger.changeset(companion_id)
79+
if changeset["status"] != "companion_staged":
80+
return changeset["status"]
81+
if rt.outbox is None or not lifecycle.auto_merge or not lifecycle.publishes:
82+
rt.ledger.update_changeset(companion_id, status="shadow_recorded")
83+
return "shadow_recorded"
84+
data = rt.load_changeset_files(companion_id)
85+
from .merge import issue_once
86+
87+
issue_once(rt, lifecycle.repo, changeset, "open_companion_pr", {
88+
"changeset_id": companion_id, "base_sha": data["base_sha"],
89+
"branch": f"kb/{lifecycle.repo}/{companion_id}", "files": data["files"], "deleted": [],
90+
"title": f"knowledge({lifecycle.repo}): update references for {changeset['detail']['for_changeset']}",
91+
"body": ("Companion to a knowledge change that retires or replaces rules cited here "
92+
f"(`{changeset['detail']['for_changeset']}`). Drafted by the Copilot knowledge service; "
93+
"review, mark ready and merge it by hand. The knowledge change is rebuilt once this merges."),
94+
})
95+
rt.ledger.update_changeset(companion_id, status="pr_requested")
96+
return "pr_requested"

‎src/infermatrix_copilot/kb_service/merge.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def knowledge_repository() -> str:
5353
# the transition each outbox item kind drives: (state before, state after)
5454
TRANSITIONS = {
5555
"open_pr": ("pr_requested", "pr_open"),
56+
"open_companion_pr": ("pr_requested", "companion_open"),
5657
"post_verdict": ("pr_open", "verdict_posted"),
5758
"enqueue": ("verdict_posted", "queued"),
5859
}
@@ -135,7 +136,7 @@ def apply_acks(rt, acks: list[dict]) -> None:
135136
changeset_id)
136137
continue
137138
fields = {"status": after, "pending_item": None}
138-
if kind == "open_pr":
139+
if kind in ("open_pr", "open_companion_pr"):
139140
fields.update(pr_number=int(ack["pr"]), head_sha=str(ack["head_sha"]),
140141
branch=str(ack.get("branch", "")))
141142
elif kind == "enqueue":
@@ -305,10 +306,12 @@ def to_people(reason: str) -> None:
305306
# the rebuild itself did not pass the gate: nothing replaces the PR
306307
to_people(f"rebuild {new_id} on current main did not pass the gate ({staged['status']})")
307308
return None
308-
rt.ledger.update_changeset(changeset["id"], status="superseding",
309+
no_pr = changeset.get("pr_number") is None
310+
rt.ledger.update_changeset(changeset["id"], status="superseded" if no_pr else "superseding",
309311
detail={**detail, "rebuilt_as": new_id, "superseded_because": why})
310312
_outcome(rt, changeset, "superseded", reason=why, replaced_by=new_id)
311-
_request_close(rt, lifecycle.repo, rt.ledger.changeset(changeset["id"]))
313+
if not no_pr:
314+
_request_close(rt, lifecycle.repo, rt.ledger.changeset(changeset["id"]))
312315
publish(rt, lifecycle, new_id)
313316
return new_id
314317

@@ -332,7 +335,8 @@ def advance(rt, lifecycle) -> list[str]:
332335
queued = rt.ledger.changesets(lifecycle.repo, ("queued",)) + [
333336
cs for cs in rt.ledger.changesets(lifecycle.repo, ("verdict_posted",))
334337
if _enqueue_outstanding(rt, cs) or cs["detail"].get("withdrawn")]
335-
for changeset in rt.ledger.changesets(lifecycle.repo, (*IN_FLIGHT, "superseding", "paused")):
338+
for changeset in rt.ledger.changesets(lifecycle.repo, (*IN_FLIGHT, "superseding", "paused",
339+
"companion_open")):
336340
number = changeset["pr_number"]
337341
if number is None:
338342
continue
@@ -344,6 +348,42 @@ def advance(rt, lifecycle) -> list[str]:
344348
_pause_unconfirmed(rt, lifecycle.repo, changeset, events)
345349
events.append(f"observe_failed {changeset['id']}")
346350
continue
351+
if changeset["status"] == "companion_open":
352+
# people review and merge companions; the service only follows them
353+
waiting = [cs for cs in rt.ledger.changesets(lifecycle.repo, ("companion_pending",))
354+
if cs["detail"].get("companion") == changeset["id"]]
355+
target = changeset["detail"].get("for_changeset")
356+
if target and not waiting:
357+
# the companion names its change; a crash between staging the
358+
# companion and linking the change leaves it "human" and unlinked
359+
parent = rt.ledger.changeset(target)
360+
if parent["status"] in ("companion_pending", "human") and not parent["detail"].get("rebuilt_as"):
361+
waiting = [parent]
362+
if pr.get("merged"):
363+
if waiting and (paused or not getattr(rt, "lease_owner", None)):
364+
continue # rebuild only unpaused and under the scheduler's lease: next pass
365+
events.append(f"companion_merged {changeset['id']}")
366+
retry = False
367+
for knowledge in waiting:
368+
try:
369+
new_id = rebuild(rt, lifecycle, knowledge, "its companion PR merged")
370+
except Exception as exc: # noqa: BLE001 - e.g. main not fetchable: next pass
371+
retry = True
372+
events.append(f"rebuild_retry {knowledge['id']}: {exc}")
373+
continue
374+
events.append(f"rebuilt {knowledge['id']} as {new_id}" if new_id
375+
else f"rebuild_failed {knowledge['id']}")
376+
if not retry: # only once every waiting change was handled
377+
rt.ledger.update_changeset(changeset["id"], status="merged",
378+
merge_sha=str(pr.get("merge_commit_sha") or ""))
379+
elif pr.get("state") == "closed":
380+
rt.ledger.update_changeset(changeset["id"], status="closed")
381+
for knowledge in waiting:
382+
rt.ledger.update_changeset(knowledge["id"], status="human")
383+
rt.ledger.enqueue_human(lifecycle.repo, f"companion PR #{number} was closed unmerged",
384+
knowledge["id"])
385+
events.append(f"companion_closed {changeset['id']}")
386+
continue
347387
if changeset["status"] == "superseding":
348388
# the replaced PR stays tracked until GitHub shows it closed; the
349389
# close item is re-issued whenever the previous one expired

‎src/infermatrix_copilot/kb_service/publisher.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
ARCHIVE_NAME = re.compile(r"traces-\d{6}-\d{8}-\d{6}\.tar\.gz")
4848
GOVERNED_PATH = re.compile(r"knowledge/(?:repos/[A-Za-z0-9._-]+|general)/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+\.(?:md|yaml)")
4949
BRANCH = re.compile(r"kb/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
50+
# a companion may only touch these (never knowledge/, .github/, src/, tools/)
51+
COMPANION_PATH = re.compile(r"(?:skills|plugins|adapters|doc|playbooks)/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+")
52+
COMPANION_LABEL = "kb:companion"
53+
# companions are recognized by their branch too (kb/<repo>/<repo>-companion-<id>),
54+
# so a label that failed to apply cannot make one mergeable by automation
55+
COMPANION_BRANCH = re.compile(r"kb/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+-companion-[0-9a-f]+")
5056
HOLD_LABEL = "kb:hold"
5157
PUSHING = frozenset({"open_pr", "open_companion_pr"})
5258
# retried until done, never acked as failed: stops (pause, close) and reports
@@ -327,16 +333,17 @@ def _checked_head(self, item: OutboxItem) -> dict:
327333
raise PublishError(f"PR #{item.body['pr']} head moved to {str(pr.get('headRefOid'))[:12]}")
328334
return pr
329335

330-
def _do_open_pr(self, item: OutboxItem, *, draft: bool = False) -> dict:
336+
def _do_open_pr(self, item: OutboxItem, *, draft: bool = False, allowed: re.Pattern = GOVERNED_PATH,
337+
what: str = "governed knowledge pages") -> dict:
331338
body = item.body
332339
branch = str(body["branch"])
333340
if not BRANCH.fullmatch(branch):
334341
raise PublishError(f"refusing branch name {branch!r}")
335342
files: dict[str, str] = dict(body.get("files") or {})
336343
deleted = [str(p) for p in body.get("deleted") or []]
337344
for path in [*files, *deleted]:
338-
if not GOVERNED_PATH.fullmatch(path) or ".." in path.split("/"):
339-
raise PublishError(f"refusing to write outside governed knowledge pages: {path}")
345+
if not allowed.fullmatch(path) or ".." in path.split("/"):
346+
raise PublishError(f"refusing to write outside {what}: {path}")
340347
commit = self._build_commit(str(body["base_sha"]), files, deleted, str(body["title"]),
341348
when=item.issued_at)
342349
existing = json.loads(self.github.gh("pr", "list", "--repo", self.github.repository, "--head", branch,
@@ -365,13 +372,24 @@ def _do_open_pr(self, item: OutboxItem, *, draft: bool = False) -> dict:
365372
return {"pr": int(opened[0]["number"]), "head_sha": commit, "branch": branch}
366373

367374
def _do_open_companion_pr(self, item: OutboxItem) -> dict:
368-
return self._do_open_pr(item, draft=True) # companions are always drafts for people
375+
"""Always a draft, labelled kb:companion, and only within the companion
376+
whitelist: people review, ready and merge it; automation never does."""
377+
result = self._do_open_pr(item, draft=True, allowed=COMPANION_PATH,
378+
what="the companion whitelist (skills, plugins, adapters, doc, playbooks)")
379+
self.github.gh("pr", "edit", str(result["pr"]), "--repo", self.github.repository,
380+
"--add-label", COMPANION_LABEL, ok_fail=True)
381+
return result
382+
383+
def _refuse_companion(self, pr: dict) -> None:
384+
labelled = COMPANION_LABEL in {str(label.get("name")) for label in pr.get("labels") or []}
385+
if labelled or COMPANION_BRANCH.fullmatch(str(pr.get("headRefName") or "")):
386+
raise PublishError("a companion PR is readied and merged only by people")
369387

370388
def _do_post_verdict(self, item: OutboxItem) -> dict:
371389
"""A verdict is only issued for an unpaused repository, under its current
372390
generation, so posting one also lifts an earlier pause's kb:hold label
373391
(first, so the precheck the comment triggers no longer sees it)."""
374-
self._checked_head(item)
392+
self._refuse_companion(self._checked_head(item))
375393
number = str(item.body["pr"])
376394
self.github.gh("pr", "edit", number, "--repo", self.github.repository, "--remove-label", HOLD_LABEL,
377395
ok_fail=True) # fails when the label is absent; what matters is checked next
@@ -383,6 +401,7 @@ def _do_post_verdict(self, item: OutboxItem) -> dict:
383401

384402
def _do_enqueue(self, item: OutboxItem) -> dict:
385403
pr = self._checked_head(item)
404+
self._refuse_companion(pr)
386405
number = str(item.body["pr"])
387406
if pr.get("isDraft"): # a resumed PR was turned into a draft by its pause
388407
self.github.gh("pr", "ready", number, "--repo", self.github.repository)

‎src/infermatrix_copilot/kb_service/runtime.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@ def gate_and_stage(rt: KbRuntime, lifecycle: RepoLifecycle, owner: str, *, kind:
297297
"consistency": [{"owner_dir": c["owner_dir"], "verdict": c["verdict"]}
298298
for c in decision.consistency],
299299
"l1_issues": [i.to_dict() for i in decision.l1.issues]})
300+
from .companion import needs_companion, stage_companion
301+
302+
if needs_companion(decision) and not hold and not force_human and operations:
303+
# the change is fine except for citations outside knowledge/: draft the
304+
# companion PR that updates them; this change waits for it
305+
stage_companion(rt, lifecycle, owner, changeset_id, operations, decision, external, base_sha)
300306
return changeset_id
301307

302308

‎src/infermatrix_copilot/kb_service/scheduler.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .archive import make_archive
3030
from .audit import audit_main
3131
from .report import flush_reports
32+
from .companion import publish_companion
3233
from .external import poll_external
3334
from .runtime import collect_events, publish, run_intake
3435
from .sweep import UpstreamRepo, detect_release, run_sweep
@@ -127,6 +128,9 @@ def _repo_tick(self, lifecycle) -> None:
127128
for changeset in rt.ledger.changesets(lifecycle.repo, ("gated",)):
128129
status = publish(rt, lifecycle, changeset["id"])
129130
self._record(lifecycle.repo, "published", changeset=changeset["id"], status=status)
131+
for changeset in rt.ledger.changesets(lifecycle.repo, ("companion_staged",)):
132+
status = publish_companion(rt, lifecycle, changeset["id"])
133+
self._record(lifecycle.repo, "published", changeset=changeset["id"], status=status)
130134
if self._due(f"intake:{lifecycle.repo}", self.intake_every):
131135
new = collect_events(rt, lifecycle)
132136
changeset_id = run_intake(rt, lifecycle)
@@ -150,7 +154,8 @@ def _overturn_breaker(self, lifecycle) -> None:
150154
rt = self.rt
151155
# only the service's own PRs: an author closing their own PR is no overturn
152156
recent = [cs for cs in rt.ledger.changesets(lifecycle.repo, ("closed",))
153-
if cs["kind"] != "external" and rt.clock() - float(cs["updated_at"]) < OVERTURN_WINDOW]
157+
if cs["kind"] not in ("external", "companion")
158+
and rt.clock() - float(cs["updated_at"]) < OVERTURN_WINDOW]
154159
state = rt.ledger.repo_state(lifecycle.repo)
155160
if len(recent) >= OVERTURN_LIMIT and not state["paused"]:
156161
self._pause(lifecycle.repo, f"overturn breaker: {len(recent)} knowledge PRs closed unmerged within 24h")

0 commit comments

Comments
 (0)