Skip to content

Commit 13ef822

Browse files
tzhouamclaude
andcommitted
Add the knowledge publisher (kb publish) for the GPU box
The only GitHub writer for knowledge PRs. It runs as the repository owner through the box's existing gh login, which never reaches the bot host or any model environment. - publisher: each round reads the service's signed control record over SSH (or a local directory) and does nothing when it is stale. Every outbox item goes through check_item (signature, expiry, generation, pause, and auto_merge in both the control record and the publisher's OWN adapter configuration). The publisher performs the item, records the result locally, then writes a kb-ack signed with the publisher key. Items it cannot verify or must not execute are neither performed nor answered. Every decision is traced to traces/publisher.jsonl. - idempotent: an item already performed only has its ack re-sent; completion records are written atomically, and a corrupt one is set aside for people, never guessed. open_pr reuses an open PR on its branch only when its head is exactly the rebuilt commit (and a draft for companions). It rebuilds the commit deterministically from the signed contents on base_sha in a scratch index, and refuses to overwrite an existing branch with other content. It also refuses any path that is not a governed knowledge page. - actions: open_pr and open_companion_pr (always a draft). post_verdict, enqueue and update_branch first check that the PR is open at the signed head; enqueue marks a paused draft ready, then runs gh pr merge --auto --match-head-commit. pause runs procedure P: dequeue, disable auto-merge, convert to draft (verified), then label kb:hold. close closes the PR. A post_verdict (issued only for an unpaused repository under its current generation) first removes kb:hold and confirms it is gone before commenting, so pause, then resume, then re-gate, then enqueue completes. - a failed pause or close is never acknowledged: it stays in the outbox and is retried every round until it succeeds. Malformed control records and items are traced and skipped; they never stop the daemon. - transport failures (ssh down while listing or delivering an ack) are traced, never fatal: the completion record stays and the ack is re-sent next round without repeating the action. - double gate: without ALLOW_POST=1 a round only records what it would do; pushing a branch also needs ALLOW_PUSH=1. - CLI: kb publish (--remote host:/state | --local dir) [--once]. It needs KB_SERVICE_PUBKEY, KB_PUBLISHER_KEY and KB_PUBLISHER_GIT_AUTHOR, and never opens the service ledger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1671a31 commit 13ef822

5 files changed

Lines changed: 912 additions & 0 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,23 @@ stdlib + PyYAML + `cryptography`(`kb` extra)+ `.adapters` + `.knowledge_serv
6161
所有页面落定后基线才推进;兜底巡检只做 T1 与 purge。
6262
- 熔断暂停与状态变化在同一加锁事务中发布控制记录与暂停清单。
6363

64+
65+
## 2026-09-28 发布器
66+
`publisher` 在 GPU 盒上以仓库负责人的 gh 登录运行(`kb publish`),是知识 PR 唯一的 GitHub 写入方。
67+
- 每轮:经 SSH(或本地目录)读取签名控制记录,过期即整轮不动;对每个 outbox 项执行 `check_item`(签名、
68+
过期、代际、暂停、控制记录与**发布器自己的适配器配置**同时为 `auto_merge`),执行后先在本地记录结果,
69+
再写回发布器密钥签名的 `kb-ack`(字段:item_id、kind、changeset_id、ok、pr、head_sha、branch、error)。
70+
- 幂等:已执行的项只重发 ack(服务收取 ack 后删除该项);完成记录原子写入,损坏的记录移到 `.corrupt` 并等人处理,
71+
绝不猜测是否已执行;`open_pr` 只复用 head 恰为重建提交的已打开 PR(companion 还须是 draft);
72+
提交由签名内容在 `base_sha` 上用临时索引重建,日期取自该项,重试得到同一 SHA;分支已存在且内容不同则拒绝,
73+
绝不覆盖。路径必须是受治理的知识页面(`knowledge/{repos/<r>,general}/**.md|yaml`,无 `..`)。
74+
- 恢复:`post_verdict` 只在未暂停、当前代际下签发,发布它时先移除 `kb:hold` 标签并确认已移除,再发布评论,使评论触发的预检不再因暂停标签失败。
75+
失败的 `pause`/`close` 不回执,留在 outbox 中每轮重试直到成功;格式错误的控制记录或项目记入 trace 并跳过。
76+
SSH 读取或回执失败只记入 trace,不终止进程;完成记录保留,下一轮重发回执而不重复动作。
77+
- 动作:`open_pr`/`open_companion_pr`(始终 draft)、`post_verdict` 与 `enqueue`/`update_branch`(先核对 PR
78+
仍打开且 head 未变;`enqueue` 先把 draft 转为 ready,再 `gh pr merge --auto --match-head-commit`)、
79+
`pause`(程序 P:出队 → 关闭自动合并 → 转 draft,确认已是 draft,再加 `kb:hold`)、`close`。
80+
- 双重门控:无 `ALLOW_POST=1` 只记录将要执行的动作;推送分支还需 `ALLOW_PUSH=1`。无法验证或不可执行的项
81+
既不执行也不回执。每轮与每个决定都写入 `traces/publisher.jsonl`。
82+
- 测试:`test_kb_publisher.py`(端到端 open_pr → ack → pr_open、dry-run、崩溃后不重复执行、伪造/未配置/过期、
83+
越界路径、verdict/入队/暂停/关闭、暂停期间仍执行暂停、SSH 引号与回执、ack 字段)。

‎doc/features/knowledge-service.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,20 @@ python tools/build_kb_gate_bundle.py --lock # 从 PyPI 重新按哈希固定
8989
在 `.github/CODEOWNERS` 中再加入至少一名知识维护者;仓库 admin 开启合并队列(merge commit)
9090
与 ruleset(必需检查 `kb-gate` 限定 GitHub Actions 来源、Code Owner 审阅、新推送需重新批准)。
9191
在此之前,所有触碰 `knowledge/` 的 PR 都会被 `kb-gate` 拒绝(失败即关闭),代码 PR 不受影响。
92+
93+
## 发布器(GPU 盒)
94+
95+
发布器以仓库负责人现有的 `gh` 登录执行服务签名的 outbox 项,写回签名回执。凭据不离开 GPU 盒。
96+
97+
```bash
98+
infermatrix-copilot kb keygen --out ~/.infermatrix-copilot/kb-publisher.pem # 发布器密钥;公钥给服务的 KB_PUBLISHER_PUBKEY
99+
export KB_SERVICE_PUBKEY=/path/to/service.pub # 服务公钥(kb keygen 输出的一行)
100+
export KB_PUBLISHER_KEY=~/.infermatrix-copilot/kb-publisher.pem
101+
export KB_PUBLISHER_GIT_AUTHOR='Name <email>' # 知识 PR 提交的作者
102+
export KB_PUBLISHER_STATE=/data/<owner>/kb-publisher # 必须在负责人目录下(gh 包装器按目录选择登录)
103+
infermatrix-copilot kb publish --remote bot-host:/path/to/kb-state --once # 只记录(dry-run)
104+
ALLOW_POST=1 ALLOW_PUSH=1 infermatrix-copilot kb publish --remote bot-host:/path/to/kb-state # 常驻
105+
```
106+
107+
没有 `ALLOW_POST=1` 时只把将要执行的动作写入 `$KB_PUBLISHER_STATE/traces/publisher.jsonl`;推送分支还需要
108+
`ALLOW_PUSH=1`。控制记录超过 10 分钟未更新(服务可能宕机)时整轮不执行任何动作。

‎src/infermatrix_copilot/kb_service/cli.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
kb serve [--once] the scheduler (holds the single-writer lease)
1111
kb activate activate the knowledge snapshot of the repository's main now
1212
kb rollback --to SHA point `active` back at an earlier snapshot
13+
kb publish (--remote HOST:/STATE_DIR | --local DIR) [--once]
14+
the publisher (GPU box, owner's gh login): perform
15+
the signed outbox items; writes need ALLOW_POST=1
16+
(and ALLOW_PUSH=1 to push branches)
1317
1418
Pause/resume bump the repository's generation, so every outbox item issued
1519
before it is void; they then re-sign the control record and hold list at once.
@@ -20,6 +24,7 @@
2024
import argparse
2125
import json
2226
import os
27+
import re
2328
import sys
2429
import time
2530
from pathlib import Path
@@ -105,6 +110,14 @@ def main(argv: list[str] | None = None) -> int:
105110
sub.add_parser("activate")
106111
rollback = sub.add_parser("rollback")
107112
rollback.add_argument("--to", required=True)
113+
publish = sub.add_parser("publish")
114+
where = publish.add_mutually_exclusive_group(required=True)
115+
where.add_argument("--remote", help="host:/absolute/path of the service state directory (over ssh)")
116+
where.add_argument("--local", help="the service state directory on this machine")
117+
publish.add_argument("--once", action="store_true")
118+
publish.add_argument("--interval", type=float, default=60.0)
119+
publish.add_argument("--publisher-state",
120+
help="default: $KB_PUBLISHER_STATE or ~/.infermatrix-copilot/kb-publisher")
108121
args = parser.parse_args(argv)
109122

110123
if args.command == "keygen":
@@ -114,6 +127,9 @@ def main(argv: list[str] | None = None) -> int:
114127
print(public_key_text(key.public_key()))
115128
return 0
116129

130+
if args.command == "publish":
131+
return _publish(args)
132+
117133
state_dir = _state_dir(args.state_dir)
118134
ledger = _ledger(state_dir)
119135
try:
@@ -220,3 +236,38 @@ def _resume():
220236
finally:
221237
ledger.close()
222238
return 2
239+
240+
241+
def _publish(args) -> int:
242+
"""The publisher never opens the service ledger: it only sees the outbox."""
243+
from ..knowledge_service.signing import load_private_key, load_public_key
244+
from .merge import knowledge_repository
245+
from .publisher import Gh, LocalTransport, Publisher, SshTransport
246+
247+
for name in ("KB_SERVICE_PUBKEY", "KB_PUBLISHER_KEY", "KB_PUBLISHER_GIT_AUTHOR"):
248+
if not os.environ.get(name):
249+
raise SystemExit(f"{name} is not set")
250+
author = re.fullmatch(r"\s*(.+?)\s*<([^<>\s]+@[^<>\s]+)>\s*", os.environ["KB_PUBLISHER_GIT_AUTHOR"])
251+
if not author:
252+
raise SystemExit("KB_PUBLISHER_GIT_AUTHOR must look like 'Name <email>'")
253+
state = Path(args.publisher_state or os.environ.get("KB_PUBLISHER_STATE")
254+
or Path.home() / ".infermatrix-copilot" / "kb-publisher").expanduser()
255+
state.mkdir(parents=True, exist_ok=True)
256+
registry = _registry()
257+
publisher = Publisher(
258+
transport=SshTransport.parse(args.remote) if args.remote else LocalTransport(Path(args.local)),
259+
service_public_key=load_public_key(Path(os.environ["KB_SERVICE_PUBKEY"]).read_text(encoding="utf-8")),
260+
publisher_key=load_private_key(os.environ["KB_PUBLISHER_KEY"]),
261+
github=Gh(knowledge_repository(), cwd=state),
262+
state_dir=state,
263+
author=(author.group(1), author.group(2)),
264+
repo_flags={name: (lc.publishes, lc.auto_merge) for name, lc in registry.items() if lc.enabled},
265+
allow_post=os.environ.get("ALLOW_POST") == "1",
266+
allow_push=os.environ.get("ALLOW_PUSH") == "1",
267+
)
268+
if args.once:
269+
json.dump(publisher.run_once(), sys.stdout, sort_keys=True)
270+
print()
271+
return 0
272+
publisher.serve(interval=args.interval)
273+
return 0

0 commit comments

Comments
 (0)