Skip to content

Commit d40d2e9

Browse files
tzhouamclaude
andcommitted
kb init: block every stage when the adapter is not in the knowledge repository at the base
The jiuwenswarm pilot ran `kb init --stage modules` with its adapter PR still unmerged. The stage reads the adapter manifest from the knowledge repository at the base commit; it was absent there, `_manifest_text()` returned None, `_language()` degraded to "", and the stage recorded `empty` with "the adapter declares no repo.language" although the adapter declares python. That is a silent degrade (invariant 7). Deepen and harvest already blocked on the same condition, each with its own copy of the message. Every stage, the skeleton included, now checks at start that the manifest exists at the base and blocks with one shared message (`adapter_missing`) before any model call; deepen and harvest reuse it. The check runs before the cached-record short cut: the inputs digest never covered the manifest, so a record from a run before this fix would otherwise be returned as a pass. A present manifest that declares no language still scans nothing and says so on the checklist (never guess a language). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d8a3059 commit d40d2e9

6 files changed

Lines changed: 66 additions & 9 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
389389
run 却要发布、未运行或处于 blocked/publishing 的前序阶段 → `blocked`,不调模型。后续阶段缺省沿用前序阶段的钉点
390390
(`--pin` 可改,改了记入 notes);链的状态进入输入摘要。阶段没有任何改动时记为 `empty`(不是错误)。
391391
`InitRecord.coverage` 是阶段的覆盖率报告,PR 正文渲染成 before/after 表。适配器 manifest 从前序 dry run 的改动或知识
392-
仓库基点读取(`adapters/<adapter 目录名>/manifest.yaml`);`repo.language` 决定模块扫描的语言。
392+
仓库基点读取(`adapters/<adapter 目录名>/manifest.yaml`);`repo.language` 决定模块扫描的语言。**每个阶段**(含 skeleton)开始时先要求这个 manifest 在知识仓库基点存在,否则用同一条消息(`adapter_missing`)`blocked`,不调模型:adapter 在自己的 PR 里先合入,缺失绝不退化成"未声明语言"(jiuwenswarm 试点回归)。
393393
- `init_modules`(阶段 2):在钉点树上 `scan_modules_at_depth`(`source_roots`、`module_depth`、`min_module_loc`、
394394
`exclude`;adapter 未声明语言或语言未知 → 不扫描,进清单)。按当前 `_routes.yaml` 算模块覆盖率。未覆盖的模块由深到浅:
395395
已被某个 owner 部分覆盖的,**吸收**进以**最具体**方式覆盖其文件最多的 owner(路由顺序打平):只在该 owner 的

‎src/infermatrix_copilot/kb_service/init_deepen.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@
4141
from .init_budget import BudgetExhausted
4242
from .init_coverage import Owner, load_owners, make_include, most_specific, pr_weighted_coverage
4343
from .init_modules import scan_modules
44-
from .init_stages import ROUTES_NAME, _Candidate, _fence, _numbered, _one_line, _Stage, _title_of
44+
from .init_stages import (
45+
ROUTES_NAME, _Candidate, _fence, _numbered, _one_line, _Stage, _title_of, adapter_missing,
46+
)
4547
from .init_support import InitError, InitRecord, generate
4648

4749
MAX_CODE_BYTES = 100_000
@@ -331,7 +333,7 @@ def _flip(self) -> tuple[dict[str, tuple[str | None, str | None]], list[str]]:
331333
path = self._manifest_path()
332334
before = self._manifest_text()
333335
if before is None:
334-
return {}, [f"{path} does not exist in the knowledge repository at the base"]
336+
return {}, [adapter_missing(path)]
335337
try:
336338
after = flip_to_shadow(before)
337339
except InitError as exc:

‎src/infermatrix_copilot/kb_service/init_harvest.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from ..knowledge_service.lifecycle import LifecycleError, Page
5353
from ..knowledge_service.pinned_claims import Evidence
5454
from .gate import changes_between
55-
from .init_stages import MAX_EXCERPT_BYTES, _Candidate, _Stage
55+
from .init_stages import MAX_EXCERPT_BYTES, _Candidate, _Stage, adapter_missing
5656
from .init_support import STAGES, InitError, InitRecord
5757

5858
CALIBRATION_DIR = "kb-calibration"
@@ -320,7 +320,7 @@ def _write(self, cases: list[dict]) -> InitRecord:
320320
manifest_path = self._manifest_path()
321321
before = self._manifest_text()
322322
if before is None:
323-
return self._blocked([f"{manifest_path} does not exist in the knowledge repository at the base"])
323+
return self._blocked([adapter_missing(manifest_path)])
324324
try:
325325
after = set_calibration_set(before)
326326
except InitError as exc:

‎src/infermatrix_copilot/kb_service/init_stages.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ def run_stage(rt: InitRuntime, lifecycle, stage: str, *, dry_run: bool, pin: str
147147
return stage_class(rt, lifecycle, dry_run=dry_run, pin=pin, notes=notes, author=author).run()
148148

149149

150+
def adapter_missing(path: str) -> str:
151+
"""The one message every stage gives when the adapter manifest is not in
152+
the knowledge repository at the base: adapters ship in their own PR, merged
153+
before kb init runs, and a stage never guesses what an absent one says."""
154+
return f"{path} does not exist in the knowledge repository at the base"
155+
156+
150157
def _stage_class(stage: str) -> type:
151158
if stage == "skeleton":
152159
return _Skeleton
@@ -324,8 +331,14 @@ def run(self) -> InitRecord:
324331
init = lc.init
325332
self.repo_dir = lc.knowledge_dir
326333
base_sha = rt.knowledge.fetch()
334+
self._base_sha = base_sha
327335
main = rt.knowledge.knowledge_files(base_sha)
328336
chain = self._chain()
337+
self.overlay = dict(chain.repo_files)
338+
# every stage reads the adapter here (language, the flips); an unmerged
339+
# adapter PR must block, never degrade to "no language" — and never be
340+
# papered over by a record cached from a run before this check
341+
adapter_missing_problem = adapter_missing(self._manifest_path()) if self._manifest_text() is None else ""
329342
upstream = rt.upstream(lc.repo, lc.full_name)
330343
upstream.sync()
331344
pin = upstream.resolve(self.pin or chain.pin or "HEAD")
@@ -340,7 +353,7 @@ def run(self) -> InitRecord:
340353
raise InitError("a publication of this stage is pending (pushed, PR not confirmed); re-run "
341354
f"without --dry-run to finish it, or remove {record_path} to start over")
342355
return self._resume(previous)
343-
if previous is not None and previous.inputs_digest == digest \
356+
if previous is not None and previous.inputs_digest == digest and not adapter_missing_problem \
344357
and previous.status in ("dry_run", "published", "empty"):
345358
return previous
346359
if previous is not None and previous.pr.get("number") and previous.inputs_digest != digest:
@@ -353,8 +366,9 @@ def run(self) -> InitRecord:
353366
self.record.notes.append(f"pinned at {pin[:12]}, not at the earlier stages' {chain.pin[:12]}")
354367
self.budget = Budget(init.budget_usd)
355368
self.base = {**main, **chain.knowledge}
356-
self.overlay = dict(chain.repo_files)
357369
problems = chain.problems + self._precheck()
370+
if adapter_missing_problem:
371+
problems.append(adapter_missing_problem)
358372
if problems:
359373
return self._blocked(problems)
360374
schema = rt.knowledge.show(base_sha, "doc/knowledge/SCHEMA.md")
@@ -466,7 +480,7 @@ def _manifest_text(self) -> str | None:
466480
path = self._manifest_path()
467481
if path in self.overlay:
468482
return self.overlay[path]
469-
return self.rt.knowledge.show(self.record.kb_base_sha, path)
483+
return self.rt.knowledge.show(self._base_sha, path)
470484

471485
def _language(self) -> str:
472486
"""The adapter's ``repo.language`` ("" when it declares none)."""

‎test/test_kb_init_modules.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from infermatrix_copilot.kb_service.init_modules import SYSTEM_CARD, _leading_doc, declaration, routes_append_only
1515
from infermatrix_copilot.kb_service.init_stages import run_stage
16+
from infermatrix_copilot.kb_service.config import RepoLifecycle
1617
from infermatrix_copilot.kb_service.init_support import InitRecord
1718
from infermatrix_copilot.kb_service.models import ModelReply
1819
from infermatrix_copilot.knowledge_service.lifecycle import Page
@@ -203,6 +204,19 @@ def test_cards_join_an_existing_group_append_only(world):
203204
assert len(tooling) == 1 and set(tooling[0]["scope_prefixes"]) >= {"tools/fmt/", "tools/lint/"}
204205

205206

207+
def test_an_adapter_missing_at_the_base_blocks_the_modules_stage(world):
208+
"""The pilot fail-open: an absent manifest is not "no language"."""
209+
_world_with_tools(world)
210+
_skeleton(world)
211+
lifecycle = RepoLifecycle(**{**_modules_lifecycle().__dict__, "adapter_dir": Path("adapters/nope")})
212+
gateway = CardGateway()
213+
record = run_stage(_runtime(world, gateway), lifecycle, "modules", dry_run=True)
214+
assert record.status == "blocked"
215+
assert record.problems == ["adapters/nope/manifest.yaml does not exist in the knowledge repository at the base"]
216+
assert not any("no repo.language" in item for item in record.checklist)
217+
assert not [c for c in gateway.calls if c["system"] == SYSTEM_CARD]
218+
219+
206220
def test_no_language_means_no_scan_and_an_empty_stage(world):
207221
_commit(world["upstream"], TOOLS, "tools") # the adapter still declares no language
208222
_skeleton(world)

‎test/test_kb_init_skeleton.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,8 @@ def test_model_headings_in_generated_prose_never_become_rules(world):
500500

501501
def test_a_repo_tag_missing_from_the_taxonomy_blocks(world):
502502
lifecycle = RepoLifecycle(repo="newrepo", full_name="o/new", enabled=False, mode="shadow",
503-
knowledge_dir="repos/newrepo", init=InitConfig())
503+
knowledge_dir="repos/newrepo", init=InitConfig(),
504+
adapter_dir=Path("adapters/toy")) # its adapter IS at the base; only the tag is missing
504505
gateway = FakeGateway()
505506
record = run_stage(_runtime(world, gateway), lifecycle, "skeleton", dry_run=True)
506507
assert record.status == "blocked" and "taxonomy" in record.problems[0]
@@ -536,6 +537,32 @@ def test_missing_seed_blocks(world):
536537
assert record.status == "blocked" and "does not exist" in record.problems[0]
537538

538539

540+
def test_an_adapter_missing_at_the_base_blocks_the_skeleton(world):
541+
"""Regression (jiuwenswarm pilot, 2026-09-30): the adapter PR was not
542+
merged, so its manifest was absent from the knowledge repository at the
543+
base; the modules stage then read "no language" and ran empty instead of
544+
blocking. Every stage now blocks before any model call."""
545+
lifecycle = RepoLifecycle(**{**_lifecycle().__dict__, "adapter_dir": Path("adapters/nope")})
546+
gateway = FakeGateway()
547+
record = run_stage(_runtime(world, gateway), lifecycle, "skeleton", dry_run=True)
548+
assert record.status == "blocked"
549+
assert record.problems == ["adapters/nope/manifest.yaml does not exist in the knowledge repository at the base"]
550+
assert gateway.calls == []
551+
552+
553+
def test_an_adapter_missing_at_the_base_is_not_hidden_by_a_cached_record(world):
554+
"""A record from a run before this check (same inputs digest: the digest
555+
never covered the manifest) must not be returned as if the stage passed."""
556+
record = run_stage(_runtime(world), _lifecycle(), "skeleton", dry_run=True)
557+
assert record.status == "dry_run", record.problems
558+
lifecycle = RepoLifecycle(**{**_lifecycle().__dict__, "adapter_dir": Path("adapters/nope")})
559+
gateway = FakeGateway()
560+
again = run_stage(_runtime(world, gateway), lifecycle, "skeleton", dry_run=True)
561+
assert again.status == "blocked"
562+
assert again.problems == ["adapters/nope/manifest.yaml does not exist in the knowledge repository at the base"]
563+
assert gateway.calls == []
564+
565+
539566
def test_private_upstream_is_always_a_dry_run(world):
540567
lifecycle = RepoLifecycle(**{**_lifecycle().__dict__, "upstream_visibility": "private"})
541568
record = run_stage(_runtime(world), lifecycle, "skeleton", dry_run=False)

0 commit comments

Comments
 (0)