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
2 changes: 1 addition & 1 deletion .github/kb-gate/MANIFEST.sha256
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
5f6e269fbbb951f9a49c40474a3d71c556ac389f5ebd5545f0ac762f85361723 README.md
77d88b9671927ab95bc6ac9ed57aaa24226423e151843381c2a3fa90cd0cafde config.json
7ae4a3ff0372e54d4a9e5efc3887c2a00e9004b138215901d4c4d8c1ae5a1588 kb_gate.py
dad76104329e3f0b23385d87700fc0aae03996c16fc0b2527b6ef51b50a6cbec kb_gate.py
5743c4a6943c291073c5344d408eaec28e8bc7440a8ee113365588728ded5e8a l1.py
39f69aa816b83b673443601d38e57040521664c5aca407154ba40a8a35bc61d9 lifecycle.py
b0d0fab0210870f989da80f7ffba3978dbf723f4f087025524d1963c4081a009 ops.py
Expand Down
13 changes: 9 additions & 4 deletions .github/kb-gate/kb_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,16 @@ def relevant(path: str) -> bool:


def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str, final: str,
effective_base: str, stage: str) -> list[str]:
effective_base: str, stage: str, verdict: dict | None = None,
check_holds: bool = True) -> list[str]:
"""Problems with one PR's knowledge change (empty when it passes).

``pre``/``post`` are the commits (or trees) around this PR's change as it
is applied; ``final`` is the tree that would land; ``effective_base`` is
the state the change is applied on (current main, or the first parent of
the merge-group segment)."""
the merge-group segment). The publisher's local gate passes the verdict it
was handed (its signature and binding already checked) and no hold list:
a pause there is the signed control record."""
number = int(pr["number"])
entries = ctx.git.raw_diff(pre, post)
if not touches_knowledge(entries):
Expand Down Expand Up @@ -442,7 +445,7 @@ def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str,
touched = {e["path"][len(KNOWLEDGE_PREFIX):] for e in entries if e["path"].startswith(KNOWLEDGE_PREFIX)}

try:
holds = ctx.holds()
holds = ctx.holds() if check_holds else {}
if holds.get("global"):
problems.append("the knowledge service holds every repository (global pause)")
if number in {int(p) for p in holds.get("prs") or []}:
Expand All @@ -455,7 +458,9 @@ def verify_change(ctx: Context, *, pr: dict, head_sha: str, pre: str, post: str,
except (GateError, SignatureError, LifecycleError, KeyError, TypeError, ValueError) as exc:
problems.append(f"hold list: {exc}")

verdict, rejected = find_verdict(ctx, number, head_sha)
rejected: list[str] = []
if verdict is None:
verdict, rejected = find_verdict(ctx, number, head_sha)
if verdict is None:
detail = f" (rejected: {'; '.join(rejected[:3])})" if rejected else ""
problems.append(f"no valid signed verdict for PR #{number} at {head_sha[:12]}{detail}")
Expand Down
23 changes: 23 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,26 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
(不区分大小写)或别名精确匹配;歧义身份改名 `.quarantined` 隔离、从不改写;属于其他仓库的投放文件留待其处理。
邮箱是公开 issue,只接受 `KB_BUGFIX_AUTHORS` 列出的作者(未设置则不读邮箱),每个仓库各自维护评论游标。
事件 ID 与记录的 `event_id` 一致,重复投递幂等。

## 2026-09-29 v8:发布器本地门禁与合并
- 服务不再发布判定评论、不再等待 GitHub `kb-gate` 状态、不再入合并队列:`pr_open` 时签发绑定 PR/head 的判定,随
`merge` 项交给发布器(每仓库同时至多一个 `merge_requested`)。回执:成功 → `merged`(合并 SHA、落地后复核结果);
落地后复核失败 → 暂停该仓库并转人工;拒绝 → 按理由分为重建(上下文/页面哈希变化、无法干净合并、清单不符 →
`rebuild_needed`,由持有租约的调度器在当前 main 上重建)、瞬时(main 持续变化、控制记录变化、git/gh 问题 → 回到
`pr_open` 重签)、变更本身的问题(→ `gate_failed`,转人工,从不按原样重试)。未回执的 `merge` 过期后回到 `pr_open`。
v7 的 `verdict_posted`/`queued` 记录按 `pr_open` 重签。
- 落地后复核:回执中的 `passed`/`failed` 为终态(`failed` 暂停该仓库并转人工),即使 GitHub 先显示已合并、回执后到也照常处理;
`unknown`(发布器无法运行,或崩溃后恢复的合并)由服务 `verify_merged` 在自己的克隆上重跑,无法运行则下一轮重试。
- `local_gate`:发布器在自己安装的 Copilot 版本上,对 `main` tip 与 PR head 的合并树运行 `gate_verifier.verify_change`
(直接传入已验签的判定、不读暂停清单),另要求每个路径都在该项所属仓库的知识目录内、判定来源为 `auto`,并检查合并树
无新增树级问题;`post_merge_problems` 在 GitHub 实际生成的合并提交上重跑。
- 发布器 `merge`:整轮持有跨进程 `flock`(第二个实例直接退出);验签与绑定 → PR 打开且目标为 `main`、head 一致 →
取 main 与 PR head 跑本地门禁 → main 变化则重来(至多 3 次)→ 重读控制记录(暂停/代际变化即放弃)→ 写合并意图 →
`gh pr merge --merge --match-head-commit` → 读取合并提交并做落地后复核。合并之后的任何异常都不会变成失败回执;
每轮开始先恢复有意图无完成记录的项(已合并则补发回执,未合并则丢弃意图、下一轮重试)。
`gh pr merge` 报错时无法确定是否已合并:保留意图、不写记录也不回执,由下一轮恢复向 GitHub 确认。遗留的 `kb:hold`
标签(v7 暂停留下)在合并前移除并确认。
只有 GitHub 显示 `MERGED` 才算合并:若 `main` 要求合并队列或开启了自动合并,`gh pr merge` 只会入队——此时关闭自动合并并
拒绝(转人工,要求 `main` 允许发布器直接合并);只有经 GraphQL 确认既不在合并队列中(必要时 `dequeuePullRequest`)也没有自动合并,才记为拒绝,否则保留意图,由恢复流程继续取消或接收
后来发生的合并。存在未结的合并意图时该项跳过,直到恢复流程从 GitHub 得到结果。
- external 的状态集合由 `merge.IN_FLIGHT` 派生,合并中的外部 PR 不会被重复评审。
6 changes: 5 additions & 1 deletion doc/architecture/SPEC/knowledge_service.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# knowledge_service/ — provider curation components

<!-- verified-against: 2026-09-28 -->
<!-- verified-against: 2026-09-29 -->

The provider owns knowledge curation beneath the public SDK v1 facade.
`KnowledgeCurator` composes four domain components over one explicit work
Expand Down Expand Up @@ -74,3 +74,7 @@ L1 的索引检查允许为目录中已存在但未列入索引的页面补链
## 2026-09-28 human-approved:最新审阅
验证器除核对签名绑定的审阅仍为 APPROVED 外,还读取该 PR 的全部审阅:每位被引用审阅者的**最新**审阅必须仍批准当前 head;
之后的 changes requested 或 dismiss 使该审批失效(即使被绑定的那条审阅仍显示 APPROVED)。

## 2026-09-29 verify_change 供本地门禁调用
`verify_change` 新增 `verdict=`(调用方已验签与绑定的判定,跳过从 PR 评论查找)与 `check_holds=`(本地门禁不读暂停清单,
暂停由签名控制记录表达)。其余检查不变。
5 changes: 3 additions & 2 deletions doc/features/knowledge-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ infermatrix-copilot kb activate # 立即激活知识仓库 main 的
infermatrix-copilot kb rollback --to <sha> # 回指到较早的快照(下一请求生效)
```

合并流程:`open_pr` 回执 → 为该 PR 的精确 head 签发判定并发布评论 → PR 阶段 `kb-gate`
通过后入合并队列(每仓库至多 1 个)→ 合并后激活新快照。发版巡检在上游新 release 后运行
合并流程(v8):`open_pr` 回执 → 为该 PR 的精确 head 签发判定,随 `merge` 项交给 GPU 盒发布器 → 发布器在
`main` 与 PR head 的合并树上运行本地门禁,通过才 `gh pr merge`(每仓库同时至多 1 个)→ 合并后激活新快照。
本地门禁拒绝的变更从不合并:上下文变化则在当前 main 上重建,变更本身的问题转人工。发版巡检在上游新 release 后运行
T1/T2/T3 与 purge,每个规则页一个变更集,全部经过质量门。

评审服务读取知识:将 `KNOWLEDGE_ROOT` 指向 `$KB_STATE_DIR/active`。
Expand Down
11 changes: 7 additions & 4 deletions src/infermatrix_copilot/kb_service/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from ..knowledge_service.l1 import Change, check_changeset
from ..knowledge_service.ops import repo_scope
from .gate import run_gate
from .merge import knowledge_repository
from .merge import IN_FLIGHT, knowledge_repository
from .runtime import calibration_current

KIND = "external"
Expand Down Expand Up @@ -148,9 +148,12 @@ def _ours(rt, number: int) -> bool:
return False


_ALL = ("gated", "pr_requested", "pr_open", "verdict_posted", "queued", "merged", "closed", "failed", "human",
"head_changed", "stale_context", "superseded", "superseding", "gate_failed", "rebuild_failed",
"paused", "shadow_recorded", "calibration_required", "approval_withdrawn")
# every status an external change set can have; the in-flight ones come from
# the merge flow itself, so a new merge state can never be judged twice
_ALL = tuple(dict.fromkeys((
*IN_FLIGHT, "gated", "merged", "closed", "failed", "human",
"head_changed", "stale_context", "superseded", "superseding", "gate_failed", "rebuild_failed",
"paused", "shadow_recorded", "calibration_required", "approval_withdrawn")))


def poll_external(rt) -> list[str]:
Expand Down
107 changes: 107 additions & 0 deletions src/infermatrix_copilot/kb_service/local_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""The publisher's local gate: the last check before a knowledge PR is merged.

The publisher is the only thing that merges knowledge PRs (design v8). Right
before each merge it verifies, on its own machine and with the Copilot version
it has installed (never code from the PR or from main), the exact tree that
would land: the current tip of ``main`` merged with the PR head.

verdict signature + binding (repository, PR, head, issue window)
-> every changed path is a governed page of the item's own repository
-> the change as applied equals the signed manifest (base independent)
-> L1 on the change and on the merged tree, block coverage, consistency
hashes, context-base check (``gate_verifier.verify_change``)
-> no new tree-level issue on the merged tree

The same checks run once more on the merge commit GitHub actually made
(``post_merge_problems``): ``main`` can move in the seconds between the check
and the merge.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from ..knowledge_service.gate_verifier import (
Context, GateError, Git, touches_knowledge, tree_problems, verify_change,
)
from ..knowledge_service.ops import repo_scope
from ..knowledge_service.signing import SignatureError, verify
from ..knowledge_service.verdict import VerdictError, check_binding

GOVERNED_PREFIX = "knowledge/"


class LocalGateError(RuntimeError):
"""The local gate could not run (git or GitHub trouble); retried later."""


def _scope_problems(entries: list[dict], repo: str) -> list[str]:
"""Every changed path must be inside the item's own repository scope."""
problems = []
for entry in entries:
path = entry["path"]
if not path.startswith(GOVERNED_PREFIX):
problems.append(f"{path}: outside knowledge/ (split the PR)")
continue
try:
scope = repo_scope(path[len(GOVERNED_PREFIX):])
except ValueError:
problems.append(f"{path}: not a governed knowledge page")
continue
name = scope.split("/")[-1]
if name != repo:
problems.append(f"{path}: belongs to {name}, not to {repo} (one repository per PR)")
return problems


def check_verdict(envelope: Any, public_key, *, repository: str, pr: int, head_sha: str,
now: float) -> dict:
try:
verdict = verify("kb-gate-verdict", envelope, public_key)
check_binding(verdict, repository=repository, pr=pr, head_sha=head_sha, now=now)
except (SignatureError, VerdictError, KeyError, TypeError, ValueError) as exc:
raise VerdictError(f"verdict: {exc}") from exc
if verdict.get("source") != "auto":
raise VerdictError("only auto verdicts merge (there is no human-approved path)")
return verdict


def gate(clone: Path, *, repository: str, repo: str, pr: dict, head_sha: str, main_sha: str,
verdict: dict, public_key, now: float) -> list[str]:
"""Problems with merging ``head_sha`` into ``main_sha`` (empty = pass)."""
git = Git(clone)
try:
base = git.merge_base(main_sha, head_sha)
entries = git.raw_diff(base, head_sha)
if not touches_knowledge(entries):
return ["the PR changes nothing under knowledge/"]
problems = _scope_problems(entries, repo)
final = git.merge_tree(main_sha, head_sha)
except GateError as exc:
return [str(exc)]
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report infrastructure GateErrors as transient failures

If merge-base, diff, or another local Git command fails because of an I/O, clone, or process problem, this converts the GateError into a normal gate-problem list. The publisher consequently emits a GateRefused receipt with nonempty problems, while _apply_merge_ack only applies its transient "git " classification when problems is empty, so the change is permanently marked gate_failed instead of being retried. Reserve problem lists for defects in the proposed change (such as a clean-merge conflict) and propagate operational Git failures as LocalGateError/PublishError.

Useful? React with 👍 / 👎.

ctx = Context(git=git, github=None, repository=repository, public_key=public_key,
holds_loader=lambda: {}, codeowners=[], now=now)
problems += verify_change(ctx, pr=pr, head_sha=head_sha, pre=base, post=head_sha, final=final,
effective_base=main_sha, stage="local", verdict=verdict, check_holds=False)
problems += tree_problems(git, main_sha, final)
return problems


def post_merge_problems(clone: Path, *, repository: str, pr: dict, head_sha: str, merge_sha: str,
verdict: dict, public_key, now: float) -> list[str]:
"""The same checks on the merge commit that landed, against its first parent."""
git = Git(clone)
try:
parents = git.parents(merge_sha)
except GateError as exc: # not fetched yet, or git trouble: the check could not run
raise LocalGateError(str(exc)) from exc
if len(parents) != 2 or parents[1] != head_sha:
return [f"merge commit {merge_sha[:12]} is not a merge of the verified head"]
first = parents[0]
ctx = Context(git=git, github=None, repository=repository, public_key=public_key,
holds_loader=lambda: {}, codeowners=[], now=now)
landed = {**pr, "state": "open"} # it was open when it merged
problems = verify_change(ctx, pr=landed, head_sha=head_sha, pre=first, post=merge_sha, final=merge_sha,
effective_base=first, stage="local", verdict=verdict, check_holds=False)
return problems + tree_problems(git, first, merge_sha)
Loading
Loading