Skip to content

Commit 88d4ad5

Browse files
tzhouamclaude
andcommitted
pr-debug: post landed-fix records to the knowledge intake mailbox issue
The harvest step wrote its bugfix-run record only to a local drop directory, and the reviewbot that batches those records runs on another host: no pr_debug learning has ever reached a knowledge batch. When KNOWLEDGE_INTAKE_ISSUE names a mailbox issue (owner/repo#N), the step now also posts the same record as a comment carrying the bugfix-record:v1 marker and one fenced JSON block, through gh, after the drop file is written. The gate is the one that already admitted the step: an operator-configured mailbox plus a real, authorized push. It stays fail-open: a bad setting, a write error or a failed post is traced (knowledge_intake_publish_failed) and reported in outputs, never raised. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Js5CsiwSYjcAYvqti1Lp1Y
1 parent d9a5d49 commit 88d4ad5

6 files changed

Lines changed: 260 additions & 31 deletions

File tree

‎doc/RFC-knowledge-intake.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,19 @@ adopted by pinning a new wheel version.
204204
2. ReviewBot: `KNOWLEDGE_INTAKE_ENABLED=true` — merged-PR recording plus daily
205205
shadow batches; inspect typed curation results, artifacts and
206206
`knowledge-batch` output.
207-
3. Wire the bugfix channel: point this repo's `KNOWLEDGE_INTAKE_DIR` and
208-
the reviewbot's `COPILOT_INTAKE_DIR` at the **same directory** (v1
209-
assumes a shared host). Without this pair, everything else works but
210-
`pr_debug` learnings are silently absent from batches — both values
211-
default to empty/off.
207+
3. Wire the bugfix channel. Same host: point this repo's
208+
`KNOWLEDGE_INTAKE_DIR` and the reviewbot's `COPILOT_INTAKE_DIR` at the
209+
**same directory**. Different hosts (the production case): set this
210+
repo's `KNOWLEDGE_INTAKE_ISSUE` (plus `ALLOW_POST=1`, the outward-write
211+
flag every post needs) and the reviewbot's
212+
`KNOWLEDGE_INTAKE_ISSUE` to the same locked, pinned mailbox issue
213+
(`owner/repo#N`, JiusiServe/InferMatrixCopilot#135 in production); the
214+
harvest step posts each drop record there as a comment carrying
215+
`<!-- infermatrix-copilot:bugfix-record:v1 -->` and one fenced JSON
216+
block, and the reviewbot records collaborator-authored comments whose
217+
`repo` names its watched repository. Without one of these pairs,
218+
everything else works but `pr_debug` learnings are silently absent from
219+
batches — all values default to empty/off.
212220
4. Create the bot's fork of this repo; set `KNOWLEDGE_FORK_SLUG`.
213221
5. `KNOWLEDGE_PR_ENABLED=true` under `POST_MODE=review` — PRs start; every
214222
one is reviewed like any other knowledge edit.

‎doc/architecture/SPEC/config.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# config.py —— 规范
22

3-
<!-- verified-against: 2026-08-28 -->
3+
<!-- verified-against: 2026-09-08 -->
44

55
`LOC ~658 · 配置 · refactor-status: oversized`
66

@@ -19,7 +19,9 @@ MCP `repo_path` 授权面与 idempotency 保留期:
1919
checkout —— 最小权限;放宽是**运维**决定,不是调用方的)与
2020
`idem_retention_days`(默认 30,圈住 `.idem/` 索引);以及
2121
知识回流落盘目录 `knowledge_intake_dir`(`KNOWLEDGE_INTAKE_DIR`,默认空 = 关闭;
22-
消费方见 `doc/RFC-knowledge-intake.md`)。
22+
消费方见 `doc/RFC-knowledge-intake.md`)与跨主机发布目标 `knowledge_intake_issue`
23+
(`KNOWLEDGE_INTAKE_ISSUE`,`owner/repo#N`,默认空 = 关闭:harvest step 把同一条记录
24+
以带标记的 JSON 评论贴到该锁定的 mailbox issue,供另一台主机上的 reviewbot 摄取)。
2325

2426
## 公开契约
2527
带全部可调项的 `Settings`;`reviewer` / `intent`(回退到 `agent_model`);

‎doc/architecture/SPEC/engine/steps/pr.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# engine/steps/pr/ —— 规范
22

3-
<!-- verified-against: 2026-08-28 -->
3+
<!-- verified-against: 2026-09-08 -->
44

55
`LOC ~1390(6 个文件) · step 库(PR) · refactor-status: ok`
66

@@ -61,8 +61,15 @@
6161
- `pr.post_review` 是**双闸**的(**C5**)。
6262
- `pr.harvest_debug_knowledge` **只在真实推送后落盘**(push 输出存在且非 dry-run,
6363
且至少一组修复同时有 root_cause 与 verification —— 与 debug memory 同一门槛),
64-
写入 `settings.knowledge_intake_dir`(空 = 关闭,默认)。它是**刻意 fail-open**
65-
的:目录未配、dry-run、无已验证修复、写失败(trace
64+
投递到两个**彼此独立**的 sink:`settings.knowledge_intake_dir`(同主机落盘)与
65+
`settings.knowledge_intake_issue`(跨主机 mailbox),各自配置了才尝试,任一失败
66+
不压制另一个;两者都空 = 关闭(默认)。mailbox 投递(`_publish_intake_record`)把同一条记录以
67+
`<!-- infermatrix-copilot:bugfix-record:v1 -->` + 一个 fenced JSON 块通过 `gh api`
68+
贴为该 issue 的评论;它是对外写入,按不变量 5 双闸:配置了 mailbox **且**
69+
`ALLOW_POST=1`,否则只 trace `knowledge_intake_publish_skipped` 作 dry-run;贴失败
70+
trace `knowledge_intake_publish_failed` 并写 outputs `intake_publish_error`,成功写
71+
`intake_comment`。它是**刻意 fail-open**
72+
的:两处都未配、dry-run、无已验证修复、写失败(trace
6673
`knowledge_intake_write_failed` 后吞掉)都返回 ok —— 关闭学习回路
6774
**绝不能**让已落地的修复失败。它消费 executor 维护的 `state["outputs"]`,
6875
该表在 resume 时由 checkpoint 恢复(见 `test_knowledge_harvest.py` 的

‎src/infermatrix_copilot/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ class Settings(BaseSettings):
194194
# (e.g. the reviewbot's daily distillation batch). Empty disables the
195195
# drop; the step then no-ops without failing the run.
196196
knowledge_intake_dir: str = ""
197+
# Cross-host publisher for the same record (KNOWLEDGE_INTAKE_ISSUE,
198+
# "owner/repo#N"): the harvest step also posts it as a marked JSON
199+
# comment on that locked mailbox issue, so a reviewbot on another host
200+
# can ingest it. Operator opt-in; empty = off. Fail-open like the drop.
201+
knowledge_intake_issue: str = ""
197202

198203
# Engine
199204
run_root: Path = Path.home() / ".infermatrix-copilot" / "runs"

‎src/infermatrix_copilot/engine/steps/pr/debug.py‎

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import json
12+
import re
1213
from datetime import datetime, timezone
1314
from pathlib import Path
1415

@@ -199,9 +200,11 @@ async def _pr_harvest_debug_knowledge(ctx: StepContext) -> StepResult:
199200
is traced and swallowed (`knowledge_intake_write_failed`) — closing the
200201
learning loop must never fail the landed fix."""
201202
intake_dir = str(ctx.settings.knowledge_intake_dir or "").strip()
202-
if not intake_dir:
203+
intake_issue = str(ctx.settings.knowledge_intake_issue or "").strip()
204+
if not intake_dir and not intake_issue:
203205
return StepResult(True, summary="knowledge intake disabled "
204-
"(knowledge_intake_dir unset)")
206+
"(knowledge_intake_dir and "
207+
"knowledge_intake_issue unset)")
205208
outputs_map = ctx.state.get("outputs") or {}
206209
push_outputs = outputs_map.get("push")
207210
if push_outputs is None or push_outputs.get("dry_run"):
@@ -242,23 +245,92 @@ async def _pr_harvest_debug_knowledge(ctx: StepContext) -> StepResult:
242245
"groups": fixes,
243246
"created_at": datetime.now(timezone.utc).isoformat(),
244247
}
245-
try:
246-
directory = Path(intake_dir).expanduser()
247-
directory.mkdir(parents=True, exist_ok=True)
248-
final = directory / f"{ctx.run_dir.name}.json"
249-
tmp = final.with_suffix(".tmp")
250-
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=1),
251-
encoding="utf-8")
252-
tmp.replace(final)
253-
except OSError as exc:
254-
ctx.trace.record("knowledge_intake_write_failed",
255-
error=str(exc)[:300])
256-
return StepResult(True, summary=f"intake drop failed (traced): {exc}",
257-
outputs={"intake_error": str(exc)[:300]})
258-
ctx.trace.record("knowledge_intake_dropped", path=str(final),
259-
fixes=len(fixes))
248+
# Two independent sinks: the local drop (same-host consumer) and the
249+
# mailbox issue (cross-host consumer). Each is attempted when
250+
# configured, and neither one's failure suppresses the other.
251+
outputs: dict = {"fixes": len(fixes)}
252+
delivered = []
253+
if intake_dir:
254+
try:
255+
directory = Path(intake_dir).expanduser()
256+
directory.mkdir(parents=True, exist_ok=True)
257+
final = directory / f"{ctx.run_dir.name}.json"
258+
tmp = final.with_suffix(".tmp")
259+
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=1),
260+
encoding="utf-8")
261+
tmp.replace(final)
262+
ctx.trace.record("knowledge_intake_dropped", path=str(final),
263+
fixes=len(fixes))
264+
outputs["intake_path"] = str(final)
265+
delivered.append("drop file")
266+
except OSError as exc:
267+
ctx.trace.record("knowledge_intake_write_failed",
268+
error=str(exc)[:300])
269+
outputs["intake_error"] = str(exc)[:300]
270+
if intake_issue:
271+
outputs.update(_publish_intake_record(ctx, record, intake_issue))
272+
if "intake_comment" in outputs:
273+
delivered.append("mailbox issue")
274+
if not delivered:
275+
failures = ", ".join(
276+
v for k, v in outputs.items() if k.endswith("_error")
277+
)
278+
return StepResult(True,
279+
summary=f"intake delivery failed (traced): {failures}",
280+
outputs=outputs)
260281
return StepResult(True,
261282
summary=f"dropped {len(fixes)} verified fix record(s) "
262-
"for knowledge intake",
263-
outputs={"intake_path": str(final),
264-
"fixes": len(fixes)})
283+
"for knowledge intake via "
284+
+ " and ".join(delivered),
285+
outputs=outputs)
286+
287+
288+
BUGFIX_RECORD_MARKER = "<!-- infermatrix-copilot:bugfix-record:v1 -->"
289+
_INTAKE_ISSUE = re.compile(r"^(?P<slug>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(?P<number>[1-9][0-9]*)$")
290+
291+
292+
def _publish_intake_record(ctx: StepContext, record: dict, target: str) -> dict:
293+
"""Post the harvested record as a marked JSON comment on the configured
294+
mailbox issue (`settings.knowledge_intake_issue`, owner/repo#N), the
295+
cross-host counterpart of the drop directory. It is an outward write, so
296+
it is double-gated like every other one (invariant 5): the operator
297+
configures the mailbox AND sets ALLOW_POST=1; without the flag the post
298+
is a traced dry-run skip. Fail-open: any failure is traced
299+
(`knowledge_intake_publish_failed`) and reported in outputs, never raised.
300+
"""
301+
if not ctx.settings.allow_post:
302+
ctx.trace.record("knowledge_intake_publish_skipped",
303+
reason="ALLOW_POST=0 (dry-run)", issue=target)
304+
return {"intake_publish_skipped": "ALLOW_POST=0 (dry-run)"}
305+
match = _INTAKE_ISSUE.match(target)
306+
if match is None:
307+
ctx.trace.record("knowledge_intake_publish_failed",
308+
error=f"KNOWLEDGE_INTAKE_ISSUE is not owner/repo#N: {target[:80]}")
309+
return {"intake_publish_error": "bad KNOWLEDGE_INTAKE_ISSUE"}
310+
body = (BUGFIX_RECORD_MARKER + "\n```json\n"
311+
+ json.dumps(record, ensure_ascii=False, indent=1) + "\n```\n")
312+
body_path = ctx.run_dir / "knowledge-intake-comment.md"
313+
try:
314+
body_path.write_text(body, encoding="utf-8")
315+
except OSError as exc:
316+
ctx.trace.record("knowledge_intake_publish_failed", error=str(exc)[:300])
317+
return {"intake_publish_error": str(exc)[:300]}
318+
try:
319+
code, out = _gh(["api", f"repos/{match.group('slug')}/issues/"
320+
f"{match.group('number')}/comments",
321+
"-X", "POST", "-F", f"body=@{body_path}"])
322+
except Exception as exc: # noqa: BLE001 — a timeout or launch error
323+
# must not turn a landed fix into a BLOCKED step.
324+
detail = f"{type(exc).__name__}: {exc}"[:300]
325+
ctx.trace.record("knowledge_intake_publish_failed", error=detail)
326+
return {"intake_publish_error": detail}
327+
if code != 0:
328+
ctx.trace.record("knowledge_intake_publish_failed",
329+
error=(out or f"gh exit {code}")[:300])
330+
return {"intake_publish_error": (out or f"gh exit {code}")[:300]}
331+
try:
332+
url = str(json.loads(out).get("html_url") or "")
333+
except (ValueError, AttributeError):
334+
url = ""
335+
ctx.trace.record("knowledge_intake_published", issue=target, url=url)
336+
return {"intake_comment": url or target}

‎test/test_knowledge_harvest.py‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,148 @@ def test_landed_fix_writes_one_record(registry, settings, trace, tmp_path):
8989
assert list((tmp_path / "intake").iterdir()) == [path]
9090

9191

92+
def _capture_gh(monkeypatch, *, code=0, out=None):
93+
import infermatrix_copilot.engine.steps.pr.debug as debug_module
94+
calls = []
95+
96+
def fake_gh(args, cwd=None):
97+
calls.append(list(args))
98+
return code, (json.dumps({"html_url": "https://github.com/c/copilot/"
99+
"issues/135#issuecomment-9"})
100+
if out is None else out)
101+
102+
monkeypatch.setattr(debug_module, "_gh", fake_gh)
103+
return calls
104+
105+
106+
def test_landed_fix_is_posted_to_the_mailbox_issue(
107+
registry, settings, trace, tmp_path, monkeypatch
108+
):
109+
settings.knowledge_intake_dir = str(tmp_path / "intake")
110+
settings.knowledge_intake_issue = "c/copilot#135"
111+
settings.allow_post = True
112+
settings.repo_full_names = {"demo": "owner/demo"}
113+
calls = _capture_gh(monkeypatch)
114+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
115+
state=_state())
116+
assert result.ok and "via drop file and mailbox issue" in result.summary
117+
assert result.outputs["intake_comment"].endswith("#issuecomment-9")
118+
assert calls == [["api", "repos/c/copilot/issues/135/comments", "-X", "POST",
119+
"-F", f"body=@{tmp_path / 'run-x' / 'knowledge-intake-comment.md'}"]]
120+
body = (tmp_path / "run-x" / "knowledge-intake-comment.md").read_text(
121+
encoding="utf-8"
122+
)
123+
assert body.startswith("<!-- infermatrix-copilot:bugfix-record:v1 -->\n```json\n")
124+
posted = json.loads(body.split("```json\n", 1)[1].rsplit("```", 1)[0])
125+
# The comment carries exactly the record the drop file carries.
126+
dropped = json.loads((tmp_path / "intake" / "run-x.json").read_text(encoding="utf-8"))
127+
assert posted == dropped and posted["repo"] == "owner/demo"
128+
assert list(trace.events("knowledge_intake_published"))
129+
130+
131+
def test_mailbox_post_failure_is_swallowed(
132+
registry, settings, trace, tmp_path, monkeypatch
133+
):
134+
settings.knowledge_intake_dir = str(tmp_path / "intake")
135+
settings.knowledge_intake_issue = "c/copilot#135"
136+
settings.allow_post = True
137+
_capture_gh(monkeypatch, code=1, out="HTTP 403: locked")
138+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
139+
state=_state())
140+
assert result.ok # the landed fix and its drop file are not undone
141+
assert (tmp_path / "intake" / "run-x.json").is_file()
142+
assert "via drop file" in result.summary
143+
assert result.outputs["intake_publish_error"] == "HTTP 403: locked"
144+
assert "intake_comment" not in result.outputs
145+
assert list(trace.events("knowledge_intake_publish_failed"))
146+
147+
148+
def test_mailbox_publisher_exception_is_swallowed(
149+
registry, settings, trace, tmp_path, monkeypatch
150+
):
151+
import subprocess
152+
153+
import infermatrix_copilot.engine.steps.pr.debug as debug_module
154+
155+
def hanging_gh(args, cwd=None):
156+
raise subprocess.TimeoutExpired(cmd=["gh", *args], timeout=120)
157+
158+
monkeypatch.setattr(debug_module, "_gh", hanging_gh)
159+
settings.knowledge_intake_dir = str(tmp_path / "intake")
160+
settings.knowledge_intake_issue = "c/copilot#135"
161+
settings.allow_post = True
162+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
163+
state=_state())
164+
assert result.ok and "via drop file" in result.summary
165+
assert result.outputs["intake_publish_error"].startswith("TimeoutExpired")
166+
assert list(trace.events("knowledge_intake_publish_failed"))
167+
168+
169+
def test_mailbox_post_is_dry_run_without_allow_post(
170+
registry, settings, trace, tmp_path, monkeypatch
171+
):
172+
"""Invariant 5: an outward write needs the env flag too."""
173+
settings.knowledge_intake_dir = str(tmp_path / "intake")
174+
settings.knowledge_intake_issue = "c/copilot#135"
175+
settings.allow_post = False
176+
calls = _capture_gh(monkeypatch)
177+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
178+
state=_state())
179+
assert result.ok and calls == []
180+
assert "via drop file" in result.summary
181+
assert result.outputs["intake_publish_skipped"].startswith("ALLOW_POST=0")
182+
assert list(trace.events("knowledge_intake_publish_skipped"))
183+
184+
185+
def test_mailbox_is_off_without_configuration(
186+
registry, settings, trace, tmp_path, monkeypatch
187+
):
188+
settings.knowledge_intake_dir = str(tmp_path / "intake")
189+
calls = _capture_gh(monkeypatch)
190+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
191+
state=_state())
192+
assert result.ok and calls == []
193+
assert "intake_comment" not in result.outputs
194+
195+
196+
def test_mailbox_only_configuration_posts_without_a_drop_dir(
197+
registry, settings, trace, tmp_path, monkeypatch
198+
):
199+
"""The production shape: no local consumer, only the mailbox."""
200+
settings.knowledge_intake_issue = "c/copilot#135"
201+
settings.allow_post = True
202+
calls = _capture_gh(monkeypatch)
203+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
204+
state=_state())
205+
assert result.ok and "via mailbox issue" in result.summary
206+
assert len(calls) == 1 and "intake_comment" in result.outputs
207+
assert "intake_path" not in result.outputs
208+
assert not (tmp_path / "intake").exists()
209+
210+
211+
def test_drop_failure_does_not_suppress_the_mailbox(
212+
registry, settings, trace, tmp_path, monkeypatch
213+
):
214+
blocker = tmp_path / "intake"
215+
blocker.write_text("a file where the directory should be")
216+
settings.knowledge_intake_dir = str(blocker)
217+
settings.knowledge_intake_issue = "c/copilot#135"
218+
settings.allow_post = True
219+
calls = _capture_gh(monkeypatch)
220+
result = _run(registry, settings, tmp_path=tmp_path, trace=trace,
221+
state=_state())
222+
assert result.ok and "via mailbox issue" in result.summary
223+
assert "intake_error" in result.outputs and "intake_comment" in result.outputs
224+
assert len(calls) == 1
225+
226+
92227
def test_write_failure_is_swallowed(registry, settings, trace, tmp_path):
93228
blocker = tmp_path / "intake"
94229
blocker.write_text("a file where the directory should be")
95230
settings.knowledge_intake_dir = str(blocker)
96231
result = _run(registry, settings, trace, tmp_path, _state())
97232
assert result.ok # the landed fix must never be failed by the drop
98-
assert "intake drop failed" in result.summary
233+
assert "intake delivery failed" in result.summary
99234
assert "intake_error" in result.outputs
100235

101236

0 commit comments

Comments
 (0)