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
6 changes: 6 additions & 0 deletions doc/architecture/SPEC/kb_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,9 @@ issue(同一报告只开一个),标签不存在时不带标签重试,绝
否则运行服务实际生效的 `run_root`(环境变量或其支持的 `.env` 文件)。切换激活快照时记录旧快照的停用时间;
停用不足 1 小时的快照不删除——在切换前刚解析到它的预留会在此窗口内写下钉住记录(运行服务在其他进程中,无法共享
激活锁,因此以宽限期协调)。测试通过自动 fixture 把 `KB_RUN_ROOTS` 指向空目录,从不读取真实运行目录。


## 2026-09-29 激活前的格式检查
`verify_snapshot` 在其他检查之前核对快照声明的知识格式:未声明或不在本 Copilot 支持的 `SUPPORTED_FORMATS` 中 →
`ActivationError`,`active` 保持不变(先升级 Copilot 再激活新格式的知识)。快照随顶层 Markdown 一起携带
`_format.yaml`。
7 changes: 6 additions & 1 deletion doc/architecture/SPEC/knowledge_view.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# knowledge_view.py —— 规范

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

`LOC ~170 · 每请求的知识树视图(打包知识或激活快照) · refactor-status: stable`

Expand Down Expand Up @@ -35,3 +35,8 @@ stdlib + `.sdk._resources`。叶子模块。
## 2026-09-28 可记录的快照标识
`public_snapshot`:激活快照 ID、`packaged` 或 `unverified`(开发树的绝对路径不外泄);`tree_sha256`:已验证快照
按清单重算的树哈希。`direct_routing` 的 `diagnostics.knowledge_snapshot` 与 SDK 计划都使用它们。

## 2026-09-29 知识格式版本
知识树在顶层 `knowledge/_format.yaml` 声明其格式(`format_version`;当前为 2:规则生命周期尾注、类型化操作、
`_routes.yaml`、`_tombstones.yaml`)。`knowledge_format(root)` 读取它,`build_manifest` 把它记入 `MANIFEST.json` 的
`knowledge_format`。该文件位于自动合并白名单之外,改动它需要人工。
1 change: 1 addition & 0 deletions knowledge/_format.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
format_version: 2
7 changes: 7 additions & 0 deletions src/infermatrix_copilot/kb_service/activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ def verify_snapshot(snapshot: Path) -> KnowledgeView:
view.path(rel)
except (KnowledgeViewError, FileNotFoundError, ValueError) as exc:
raise ActivationError(f"snapshot content does not match its manifest: {exc}") from exc
from ..knowledge_view import SUPPORTED_FORMATS, knowledge_format

declared = knowledge_format(view.root)
if declared not in SUPPORTED_FORMATS:
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce the format in every snapshot reader

When the knowledge scheduler and review service are upgraded independently, a newer scheduler can activate format 3 while an older, long-running review worker still resolves the shared $KB_STATE_DIR/active link. That worker calls KnowledgeView.current()/_load_snapshot() directly and never executes this verify_snapshot() check; _load_snapshot() currently accepts the format-3 manifest, so the old worker serves knowledge it cannot interpret. The same bypass occurs when an unsupported snapshot is supplied directly via KNOWLEDGE_ROOT. Validate knowledge_format against SUPPORTED_FORMATS in the snapshot-loading path as well, so every consumer fails closed rather than only the process performing activation.

Useful? React with 👍 / 👎.

raise ActivationError(
f"knowledge format {declared if declared is not None else '(undeclared)'} is not one this Copilot "
f"reads ({sorted(SUPPORTED_FORMATS)}); upgrade the Copilot before activating this knowledge")
files = {p.relative_to(view.root).as_posix(): p.read_text(encoding="utf-8")
for p in view.root.rglob("*") if p.is_file() and p.suffix in (".md", ".yaml")}
issues = check_tree({k: v for k, v in files.items() if k.startswith(("repos/", "general/"))})
Expand Down
7 changes: 4 additions & 3 deletions src/infermatrix_copilot/kb_service/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,10 @@ def knowledge_files(self, rev: str) -> dict[str, str]:
return {name.removeprefix("knowledge/"): text for name, text in files.items()}

def top_level_knowledge(self, rev: str) -> dict[str, str]:
"""knowledge/*.md at the top of the tree (AGENTS.md, README.md, ...):
not governed by the gate, but served with every snapshot."""
files = self._texts(rev, ("knowledge/",), (".md",))
"""knowledge/*.md at the top of the tree (AGENTS.md, README.md, ...) and
the format declaration: not governed by the gate, but served with every
snapshot."""
files = self._texts(rev, ("knowledge/",), (".md", "_format.yaml"))
return {name.removeprefix("knowledge/"): text for name, text in files.items()
if "/" not in name.removeprefix("knowledge/")}

Expand Down
20 changes: 20 additions & 0 deletions src/infermatrix_copilot/knowledge_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@
KNOWLEDGE_ROOT_ENV = "KNOWLEDGE_ROOT"
MANIFEST_NAME = "MANIFEST.json"
MANIFEST_SCHEMA_VERSION = 1
# The knowledge tree declares the format it is written in (rule lifecycle
# footers, typed operations, _routes.yaml, _tombstones.yaml). A Copilot only
# activates snapshots in a format it reads. The file sits at the top of
# knowledge/, outside the auto-merge whitelist: changing it takes a person.
FORMAT_FILE = "_format.yaml"
SUPPORTED_FORMATS = frozenset({2})


def knowledge_format(root: Path) -> int | None:
"""The declared format of the knowledge tree at ``root`` (None: undeclared or unreadable)."""
import yaml

try:
data = yaml.safe_load((Path(root) / FORMAT_FILE).read_text(encoding="utf-8"))
value = data["format_version"]
except (OSError, TypeError, KeyError, yaml.YAMLError):
return None
# exactly an integer: 2.9, "2", true or .inf are malformed, never coerced
return value if type(value) is int else None


class KnowledgeViewError(RuntimeError):
Expand Down Expand Up @@ -191,6 +210,7 @@ def build_manifest(root: Path, snapshot: str) -> dict:
return {
"schema_version": MANIFEST_SCHEMA_VERSION,
"snapshot": snapshot,
"knowledge_format": knowledge_format(root),
"files": files,
"tree_sha256": tree.hexdigest(),
}
38 changes: 38 additions & 0 deletions test/test_kb_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,3 +781,41 @@ def test_pin_discovery_reads_each_run_status_once(tmp_path, monkeypatch):
answers = iter([{"state": rs.INTERRUPTED, "child_pid": None}, {"state": rs.QUEUED}])
monkeypatch.setattr(rs, "read_status", lambda _d: next(answers)) # a reclaim lands between reads
assert pinned_snapshots([tmp_path / "runs"]) == {(tmp_path / "s").resolve()}


def test_activation_refuses_a_knowledge_format_this_copilot_does_not_read(tmp_path):
import json

rt, lifecycle = _flow_runtime(tmp_path)
_add_agents(rt)
good = rt.knowledge.fetch()
snapshot = activate(rt, good)
assert json.loads((snapshot / "MANIFEST.json").read_text())["knowledge_format"] == 2
newer = _commit_change(rt, "_format.yaml", "format_version: 3\n")
with pytest.raises(ActivationError, match="knowledge format 3"):
activate(rt, newer)
origin = Path(rt.knowledge.path).parent / "origin"
(origin / "knowledge" / "_format.yaml").unlink()
_git(origin, "add", "-A")
_git(origin, "-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "-m", "undeclared")
with pytest.raises(ActivationError, match="undeclared"):
activate(rt, rt.knowledge.fetch())
assert Path(active_link(rt.state_dir)).resolve().name == good # still the good one


def test_the_packaged_knowledge_declares_a_supported_format():
from infermatrix_copilot.knowledge_view import SUPPORTED_FORMATS, KnowledgeView, knowledge_format

assert knowledge_format(KnowledgeView.current().root) in SUPPORTED_FORMATS


@pytest.mark.parametrize("declared", ["2.9", "'2'", "true", ".inf", "[2]", "{}"])
def test_a_malformed_format_declaration_is_refused_not_coerced(tmp_path, declared):
rt, lifecycle = _flow_runtime(tmp_path)
_add_agents(rt)
good = rt.knowledge.fetch()
activate(rt, good)
bad = _commit_change(rt, "_format.yaml", f"format_version: {declared}\n")
with pytest.raises(ActivationError, match="undeclared"):
activate(rt, bad)
assert Path(active_link(rt.state_dir)).resolve().name == good
1 change: 1 addition & 0 deletions test/test_kb_intake_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def _knowledge_remote(tmp_path: Path) -> Path:
target = origin / "knowledge" / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
(origin / "knowledge" / "_format.yaml").write_text("format_version: 2\n", encoding="utf-8")
(origin / "skills").mkdir()
(origin / "skills" / "x.md").write_text("see DEMO-1a\n", encoding="utf-8")
subprocess.run(["git", "init", "-q", "-b", "main", str(origin)], check=True)
Expand Down
Loading