From d55b5ab47d19dde96822174419b3792306d2d7a0 Mon Sep 17 00:00:00 2001 From: Snowson <15168246+Snowson@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:38:15 +0800 Subject: [PATCH] =?UTF-8?q?harness=EF=BC=9A=E5=BC=95=E6=93=8E=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=88=B0=20delivery-harness=200efb6c0=EF=BC=88P4=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=EF=BC=89=EF=BC=8C=E5=A5=91=E7=BA=A6=E5=A4=B9?= =?UTF-8?q?=E5=85=B7=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .harness/engine:P2–P4 全部引擎演进(时间线/事件导出/CI 事件/GitHub 事实同步/合并账本/审计复原与完整性/告警汇总/评审包等);锁文件更新 - tests/test_harness_dispatch.py:FakeGitHub.wait_ci 补 detail 形参 (引擎 B71 修复后携带升级附言) - tests/test_harness_review_independent.py:Reviewer.read 返回值从 (text, model) 适配为 (text, model, basis)(评审模型依据位),三处 调用点与三处测试内 Fake 同步 - bin/verify --full 通过:Ran 556 tests, OK(G2 基线对照达成) - auto-merge.yml 与 quality.yml 与新模板有双向分叉(自有配置 vs 模板新增告警 job 等),按「接入方按需合并」留后续 PR 单独处理 --- .harness/engine.lock | 4 +- .harness/engine/agents/dispatch.py | 357 +++++--- .harness/engine/agents/dispatch_host.py | 124 +++ .../engine/agents/dispatch_observation.py | 349 ++++++++ .harness/engine/agents/github.py | 149 ++++ .harness/engine/agents/plan_review.py | 160 ++++ .harness/engine/agents/review.py | 272 +++++- .harness/engine/agents/review_pack.py | 22 +- .harness/engine/agents/review_pack_io.py | 66 ++ .harness/engine/agents/run_timeline.py | 164 ++++ .harness/engine/checks/base_tests.py | 12 + .harness/engine/checks/evidence.py | 21 +- .harness/engine/checks/hygiene.py | 21 +- .harness/engine/checks/integrity.py | 87 +- .harness/engine/checks/mutate.py | 14 + .harness/engine/checks/quality.py | 27 +- .harness/engine/checks/r1_checks.py | 17 +- .harness/engine/checks/taskbook.py | 78 +- .harness/engine/checks/verify.py | 52 +- .harness/engine/cli.py | 67 +- .harness/engine/core/alerts.py | 340 ++++++++ .harness/engine/core/common.py | 8 + .harness/engine/core/events.py | 397 +++++++++ .harness/engine/core/events_db.py | 688 +++++++++++++++ .harness/engine/core/events_io.py | 797 ++++++++++++++++++ .harness/engine/guards/command_guard.py | 235 ++++-- .harness/engine/guards/git_guard.py | 78 +- .harness/engine/guards/shell_structure.py | 29 + .harness/engine/lang/swift.py | 94 ++- .harness/engine/prompts/dispatch_prompt.md | 14 + .harness/engine/prompts/review_prompt.md | 2 +- .harness/engine/reports/audit.py | 762 +++++++++++++++++ .harness/engine/reports/audit_completeness.py | 313 +++++++ .harness/engine/reports/ci_events.py | 191 +++++ .harness/engine/reports/github_events.py | 386 +++++++++ .harness/engine/reports/ledger.py | 560 ++++++++++++ .harness/engine/reports/metrics.py | 33 +- .harness/engine/reports/trace.py | 455 ++++++++++ .harness/engine/reports/weekly.py | 8 +- .harness/engine/routing/policy.py | 87 +- .harness/engine/routing/risk.py | 72 +- .harness/engine/routing/run_check.py | 461 +++++++++- tests/test_harness_dispatch.py | 206 ++++- tests/test_harness_review_independent.py | 354 ++++++-- 44 files changed, 8218 insertions(+), 415 deletions(-) create mode 100644 .harness/engine/agents/dispatch_observation.py create mode 100644 .harness/engine/agents/github.py create mode 100644 .harness/engine/agents/plan_review.py create mode 100644 .harness/engine/agents/review_pack_io.py create mode 100644 .harness/engine/agents/run_timeline.py create mode 100644 .harness/engine/core/alerts.py create mode 100644 .harness/engine/core/events.py create mode 100644 .harness/engine/core/events_db.py create mode 100644 .harness/engine/core/events_io.py create mode 100644 .harness/engine/guards/shell_structure.py create mode 100644 .harness/engine/reports/audit.py create mode 100644 .harness/engine/reports/audit_completeness.py create mode 100644 .harness/engine/reports/ci_events.py create mode 100644 .harness/engine/reports/github_events.py create mode 100644 .harness/engine/reports/ledger.py create mode 100644 .harness/engine/reports/trace.py diff --git a/.harness/engine.lock b/.harness/engine.lock index 9af9978..9405fe1 100644 --- a/.harness/engine.lock +++ b/.harness/engine.lock @@ -1,6 +1,6 @@ { "engine": "delivery-harness", "version": "0.1.0", - "commit": "f7b79913a04206c2ff9452f540bcd1211096ff54", - "tree": "sha256:c5900fe320c95e8d98c2e2663d602bc629a1e5352add91647b29fed67ed32702" + "commit": "0efb6c08d68915ba416c6f112d8225667cc703cc", + "tree": "sha256:516079f11a28697d0453de45cb28ba44e8163e2427610bec64b52f7af8e310c4" } diff --git a/.harness/engine/agents/dispatch.py b/.harness/engine/agents/dispatch.py index ab1a256..1eabab6 100644 --- a/.harness/engine/agents/dispatch.py +++ b/.harness/engine/agents/dispatch.py @@ -15,12 +15,15 @@ 不能完成时升级:已开 PR 的在 PR 上评论,否则开议题,都加 escalation 标签;升级包预填当前状态、 已尝试的方案与证据,执行方写的「可选方案」「需要决定的问题」(build/dispatch/escalation.md)附在后面。 运行记录只存结构化摘要,完整事件流留在本机 /dispatch/runs/,不入库。 +各步骤的实际结果另由 engine/agents/dispatch_observation.py 写观察事件(B46 T105):观察是旁路, +不改变本文件的判定、预算与返回码。 bin/dispatch run docs/plans/task-005-x.md [--resume] [--model M] bin/dispatch status bin/dispatch stop --all bin/dispatch review [--reviewer opencode|pi|codex|claude-code] 独立评审(engine/agents/review.py) bin/dispatch review --pending | --watch [--interval 5] 评审全部待评审的 PR(后台常驻用 --watch) + bin/dispatch review-calibrate [--reviewer 名称] [--output 路径] 评审校准打分(不评论 PR) """ from __future__ import annotations @@ -38,8 +41,11 @@ from dataclasses import dataclass, field from pathlib import Path -from engine.agents import dispatch_host -from engine.checks import taskbook +from engine.agents import dispatch_host, run_timeline +from engine.agents import dispatch_observation as observation +from engine.agents.github import GitHub +from engine.checks import acceptance, taskbook +from engine.core import alerts from engine.core.common import ENGINE_DIR, ROOT, git, load_rules, setting PROMPT_TEMPLATE = ENGINE_DIR / "prompts" / "dispatch_prompt.md" @@ -104,6 +110,11 @@ class Attempt: usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) executor_note: str = "" + # T202:最后一轮缺失报告的安全条目与状态(reported/unknown/invalid),插在 guard_allowed 前 + # (T105 冻结断言要求它仍是末位字段)。 + missing_context: list = field(default_factory=list) + missing_context_status: str = "unknown" + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) # ---- 1 准入与 2 认领 ---- @@ -112,20 +123,26 @@ def admit(path: str, root: Path = ROOT, require_on_main: bool = True) -> Task: rel = Path(path).as_posix().removeprefix("./") reports = [report for report in taskbook.check_all(root) if report.path == rel] if not reports: + observation.admit(rel, root, problems=[f"{rel} 不是 docs/plans/task-*.md 下的任务书"]) raise Stop(f"{rel} 不是 docs/plans/task-*.md 下的任务书") report = reports[0] if rel in taskbook.load_exempt(root / ".harness" / "state" / "taskbook-exempt.txt"): - raise Stop(f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发") + problem = f"{rel} 是登记豁免的历史任务书,只检查头部,不能派发" + observation.admit(rel, root, problems=[problem]) + raise Stop(problem) if require_on_main: problem = taskbook.on_main(rel, root) if problem: report.errors.append(problem) if report.errors: + observation.admit(rel, root, problems=report.errors) raise Stop("准入未通过:\n" + "\n".join(f" - {error}" for error in report.errors)) header = report.header slug = Path(rel).stem.removeprefix("task-") - return Task(rel, header["task"], header["class"], header["risk"], header["budget"], + task = Task(rel, header["task"], header["class"], header["risk"], header["budget"], header.get("spec_refs") or [], f"task/{slug}") + observation.admit(rel, root, task=task) + return task # ---- 3 槽位 ---- @@ -174,6 +191,7 @@ def acquire_slot(root: Path, config: Config, task: Task) -> tuple[int, Path]: json.dump({"pid": os.getpid(), "task": task.id, "branch": task.branch, "started_at": _now()}, handle) return index, slot_path(root, config, index) + observation.slot(task.branch, None, problem=f"{config.slots} 个槽位都在使用中") raise Stop(f"{config.slots} 个槽位都在使用中:稍后再派发,或用 bin/dispatch status 查看") @@ -188,6 +206,85 @@ def release_slot(root: Path, index: int) -> None: (state_dir(root) / "slots" / f"{index}.json").unlink(missing_ok=True) +def _registered_worktrees(root: Path) -> set[str]: + """登记在案的工作树绝对路径(porcelain 输出是规范化路径,按 resolve 后比对,免符号路径误差)。""" + out = git("worktree", "list", "--porcelain", cwd=root) + return {str(Path(line[len("worktree "):]).resolve()) + for line in out.splitlines() if line.startswith("worktree ")} + + +def _salvage_and_remove(root: Path, slot: Path, push) -> None: + """归还一个槽位工作树(B77):分支有未推提交先推送保全,再 worktree remove。 + + 不是登记在案的工作树(普通残留目录)不动;推送/取远端状态失败原样抛出,由调用方决定 + 停止派发(回收路径)或提示后继续(结束路径)。 + """ + if not slot.exists() or str(slot.resolve()) not in _registered_worktrees(root): + return + branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=slot, check=False) + if branch and branch != "HEAD": + # fetch 失败(远端无该分支——认领推送前崩溃/推送静默失败)不拦回收:直接尝试推送保全 + try: + git("fetch", "--quiet", "origin", branch, cwd=slot) + except RuntimeError: + pass + pushed = git("rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}", + cwd=slot, check=False) + if not pushed or git("rev-list", f"origin/{branch}..{branch}", cwd=slot).split(): + push(slot, branch) + git("worktree", "remove", "--force", str(slot), cwd=root) + + +def reclaim_stale_slots(root: Path, config: Config, push) -> list[str]: + """崩溃路径无法归还时的槽位回收(B77):锁文件 pid 不存活的槽在下次派发前强制回收。 + + 槽内分支有未推提交先推送保全再删工作树;推送失败停止派发(保数据优先,不静默丢提交), + 现场保留待人工处理。返回被回收槽位曾认领的分支名(链路自愈提示用)。 + """ + locks = state_dir(root) / "slots" + if not locks.is_dir(): + return [] + reclaimed: list[str] = [] + for lock in sorted(locks.glob("*.json"), key=lambda path: int(path.stem) if path.stem.isdigit() else 0): + if not lock.stem.isdigit(): + continue + try: + data = json.loads(lock.read_text() or "{}") + except ValueError: + data = {} + if _alive(data.get("pid", 0)): + continue + try: + _salvage_and_remove(root, slot_path(root, config, int(lock.stem)), push) + except (RuntimeError, subprocess.CalledProcessError) as error: + raise Stop(f"槽位 {lock.stem} 回收失败(分支提交保全未完成,工作树保留待人工处理):{error}") from error + reclaimed.append(str(data.get("branch") or "")) + lock.unlink(missing_ok=True) + return reclaimed + + +def return_slot(root: Path, config: Config, index: int, push) -> None: + """派发结束路径统一归还槽位(B77):worktree remove + 锁清理。 + + run 结束时锁已先释放;归还前先重新独占槽位锁——拿不到说明已有下一次派发认领该槽, + 不删它正准备使用的工作树。删除失败只提示,残留交给下次派发的回收路径。 + """ + lock = state_dir(root) / "slots" / f"{index}.json" + lock.parent.mkdir(parents=True, exist_ok=True) + try: + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + return + with os.fdopen(fd, "w") as handle: + json.dump({"pid": os.getpid(), "task": "return", "started_at": _now()}, handle) + try: + _salvage_and_remove(root, slot_path(root, config, index), push) + except (RuntimeError, subprocess.CalledProcessError) as error: + print(f"槽位 {index} 归还未完成(工作树保留,下次派发会回收):{error}", file=sys.stderr) + finally: + release_slot(root, index) + + def prepare_slot(root: Path, slot: Path, branch: str, resume: bool) -> None: if not slot.exists(): git("worktree", "add", "--detach", str(slot), "origin/main", cwd=root) @@ -205,7 +302,7 @@ def prepare_slot(root: Path, slot: Path, branch: str, resume: bool) -> None: # ---- 4 守卫 ---- -def prepare_guard(root: Path) -> tuple[Path, str]: +def prepare_guard(root: Path, trace: str = "") -> tuple[Path, str]: """把 origin/main 上的守卫导出到本机目录并确认可运行;返回 (扩展路径, main 的提交)。""" ref = git("rev-parse", "origin/main", cwd=root) bundle = state_dir(root) / "guard" / ref @@ -218,6 +315,7 @@ def prepare_guard(root: Path) -> tuple[Path, str]: paths = ["harness", ".pi"] if legacy else [".harness", ".pi"] archive = subprocess.run(["git", "archive", ref, *paths], cwd=root, capture_output=True, check=False) if archive.returncode != 0: + observation.guard_preflight(trace, ref, False) raise Stop(f"无法从 origin/main 导出守卫:{archive.stderr.decode(errors='replace').strip()}") subprocess.run(["tar", "-x", "-C", str(bundle)], input=archive.stdout, check=True) extension = bundle / ".pi" / "extensions" / "harness-guard.ts" @@ -225,7 +323,9 @@ def prepare_guard(root: Path) -> tuple[Path, str]: [sys.executable, *guard_cmd, "--format", "json", "--role", "implementer"], input=json.dumps({"command": "gh issue close 1"}), capture_output=True, text=True, check=False, ) - if not extension.exists() or probe.returncode != 2: + denied = extension.exists() and probe.returncode == 2 + observation.guard_preflight(trace, ref, denied) + if not denied: raise Stop("守卫预检失败:导出的守卫没有拒绝必拒绝的样例,停止派发") return extension, ref @@ -239,24 +339,37 @@ def render_prompt(task: Task, minutes: int, feedback: str) -> str: branch=task.branch, minutes=minutes, feedback=extra) -def verify_signature(slot: Path, config: Config) -> tuple[bool, str, str]: - """运行 verify;返回 (通过, 失败摘要, 失败签名)。签名去掉数字与路径,用于识别打转。""" +def _context_report(host, events: Path) -> dict: + """宿主的缺失上下文报告(B46 T202):宿主缺 parse_context 或解析失败时按 unknown 处理, + 不改变派发判定、退出码与调用序列(C0 失败隔离)。""" + parse = getattr(host, "parse_context", None) + if parse is None: + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + try: + return parse(events) + except Exception: # noqa: BLE001 报告解析失败不改变派发判定 + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + + +def verify_signature(slot: Path, config: Config) -> tuple[bool, str, str, str]: + """运行 verify;返回 (通过, 失败摘要, 失败签名, 完整输出)。签名去掉数字与路径,用于识别打转。""" result = subprocess.run(config.verify, cwd=slot, capture_output=True, text=True, check=False) + log = result.stdout + result.stderr if result.returncode == 0: - return True, "", "" + return True, "", "", log summary_file = slot / "build" / "verify" / "summary.json" failed, excerpts = [], [] if summary_file.exists(): for item in json.loads(summary_file.read_text()).get("results", []): if item.get("status") == "fail": failed.append(item["name"]) - log = slot / item.get("log", "") - tail = log.read_text(errors="replace").splitlines()[-15:] if log.is_file() else [] + log_path = slot / item.get("log", "") + tail = log_path.read_text(errors="replace").splitlines()[-15:] if log_path.is_file() else [] excerpts.append(f"### {item['name']}\n" + "\n".join(tail)) text = "\n\n".join(excerpts) or (result.stdout + result.stderr)[-2000:] normalized = re.sub(r"\d+(\.\d+)?", "#", re.sub(r"/\S+/", "/…/", text)) signature = ",".join(failed) + ":" + hashlib.sha256(normalized.encode()).hexdigest()[:12] - return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature + return False, f"`bin/verify` 未通过:{'、'.join(failed) or '见输出'}\n\n```\n{text[-3000:]}\n```", signature, log def worktree_problem(slot: Path, base: str) -> str | None: @@ -278,6 +391,7 @@ def __init__(self, root: Path, config: Config, github, host, identity: dict[str, } self.identity = identity self.used_seconds = 0.0 + self.acquired: int | None = None # 已认领的槽位号;归还(return_slot)由派发进程结束路径统一做(B77) def run_executor(self, task: Task, slot: Path, guard: Path, prompt: str, events: Path) -> dispatch_host.RunResult: budget_seconds = task.budget["wall_clock_min"] * 60 @@ -291,9 +405,19 @@ def run_executor(self, task: Task, slot: Path, guard: Path, prompt: str, events: ) self.used_seconds += seconds model, usage, denials = self.host.parse(events) - return dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials) - - def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace_dir: Path) -> tuple[Attempt, list[str]]: + counts = observation.tool_counts(self.host, events) + context = _context_report(self.host, events) + result = dispatch_host.RunResult(exit_kind, code, seconds, model, usage, denials, + missing_context=context["items"], + missing_context_status=context["status"], + guard_denied=counts["guard_denied"], + guard_allowed=counts["guard_allowed"]) + observation.executor_round(task.branch, slot, prompt, events, result, + {"name": self.host.name, "version": self.host.version()}, counts) + return result + + def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace_dir: Path, + pr: int | None = None) -> tuple[Attempt, list[str]]: """5 与 6:执行方 → verify,失败带摘要重试;返回本次尝试与每轮提示词。""" attempt, prompts = Attempt(ok=False, exit="retries"), [] base = git("rev-parse", "HEAD", cwd=slot) @@ -303,15 +427,23 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace prompt = render_prompt(task, task.budget["wall_clock_min"], feedback) prompts.append(prompt) result = self.run_executor(task, slot, guard, prompt, trace_dir / f"round-{round_no + 1}.jsonl") + alerts.guard_round_alert(task.branch, task.id, result.guard_denied, pr=pr, gh=self.github) attempt.executor_seconds += result.seconds attempt.model = result.model or attempt.model for key, value in result.usage.items(): attempt.usage[key] = round(attempt.usage.get(key, 0) + value, 6) for key, value in result.guard_denials.items(): attempt.guard_denials[key] = attempt.guard_denials.get(key, 0) + value + attempt.guard_allowed += result.guard_allowed + # 最后报告以最后一轮为准:每轮覆盖,抽取只读、不改变判定与退出码 + attempt.missing_context = result.missing_context + attempt.missing_context_status = result.missing_context_status note = slot / ESCALATION_FILE if note.exists(): attempt.executor_note = note.read_text(encoding="utf-8")[:4000] + observation.clarify(task.branch, note, + context={"status": result.missing_context_status, + "items": result.missing_context}) attempt.exit = "clarify" return attempt, prompts if result.exit != "ok": @@ -319,14 +451,16 @@ def local_rounds(self, task: Task, slot: Path, guard: Path, feedback: str, trace return attempt, prompts problem = worktree_problem(slot, base) if problem: - ok, summary, signature = False, problem, problem + ok, summary, signature, log = False, problem, problem, "" else: - ok, summary, signature = verify_signature(slot, self.config) + ok, summary, signature, log = verify_signature(slot, self.config) + repeat = bool(attempt.signatures) and attempt.signatures[-1] == signature + observation.local_verify(task.branch, slot, base, ok, signature, log, repeat) if ok: attempt.ok, attempt.exit = True, "ok" return attempt, prompts attempt.verify_summary = summary - if attempt.signatures and attempt.signatures[-1] == signature: + if repeat: attempt.signatures.append(signature) attempt.exit = "loop" return attempt, prompts @@ -340,7 +474,8 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr guard_ref: str, started: str, ci_rounds: int) -> str: folder = slot / "docs" / "runs" / task.path.split("/")[-1].removesuffix(".md") folder.mkdir(parents=True, exist_ok=True) - prompt_text = "\n\n---\n\n".join(prompts) + # B77 落盘前自检:prompt 快照的 CI/runner 工作区路径先占位,sha256 按占位后字节计算。 + prompt_text = run_timeline.placeholder_workspace("\n\n---\n\n".join(prompts)) (folder / f"{number}.prompt.md").write_text(prompt_text, encoding="utf-8") record = { "task": task.id, @@ -361,11 +496,17 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr "failure_signatures": attempt.signatures, "ci_rounds_before": ci_rounds, "guard_denials": attempt.guard_denials, + "missing_context": attempt.missing_context, + "missing_context_status": attempt.missing_context_status, "gen_ai.usage.input_tokens": attempt.usage.get("input_tokens"), "gen_ai.usage.output_tokens": attempt.usage.get("output_tokens"), "cost": attempt.usage.get("cost"), "escalation": None if attempt.ok else attempt.exit, } + # T201 时间线:落盘前取本机链头,stages/anchors 安全摘要写进记录;记录提交后固定 run_record 锚点。 + timeline, head = run_timeline.record_fields(task.branch) + record.update(timeline) + run_timeline.fit_record(record) # B77 自检:记录超 512KB 先截断 stages 并标注,不让卫生守卫在提交步杀派发 (folder / f"{number}.json").write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n") paths = [str(folder / f"{number}.json"), str(folder / f"{number}.prompt.md")] git("add", "--", *paths, cwd=slot) @@ -376,44 +517,71 @@ def write_record(self, task: Task, slot: Path, number: int, attempt: Attempt, pr message += "Risk: R1\n" # 与执行方的提交一致,否则 R1 声明会被这次记录提交打断 subprocess.run(["git", "commit", "--quiet", "-m", message], cwd=slot, env={**os.environ, **self.identity}, check=True) + run_timeline.fix_anchor(task.branch, head) return record["prompt_sha256"] # ---- 主流程 ---- def run(self, path: str, resume: bool = False) -> int: task = admit(path, self.root) + reclaimed = reclaim_stale_slots(self.root, self.config, self.github.push) + if resume and task.branch in reclaimed: + self._chain_hint(task.branch) if self.github.remote_branch_exists(task.branch) and not resume: + observation.claim(task.branch, False) raise Stop(f"远端已有 {task.branch}:该任务已被认领(续跑加 --resume)") - guard, guard_ref = prepare_guard(self.root) + guard, guard_ref = prepare_guard(self.root, task.branch) index, slot = acquire_slot(self.root, self.config, task) + self.acquired = index try: prepare_slot(self.root, slot, task.branch, resume) + observation.slot(task.branch, index, git("rev-parse", "HEAD", cwd=slot), + git("rev-parse", "origin/main", cwd=self.root)) if not resume: self.github.push(slot, task.branch) # 分支即认领 + observation.claim(task.branch, True) return self._loop(task, index, slot, guard, guard_ref) finally: release_slot(self.root, index) + def _chain_hint(self, branch: str) -> None: + """等待链自愈提示(B77):上一轮派发进程异常退出(槽位锁 pid 不存活)、分支 head 已保全到远端时, + 提示设计方核对 CI 与评审结论;不自动重跑评审,缺链由设计方手动接链。""" + print(f"链路自检:上一轮派发异常退出,分支 {branch} 的 head 已在远端;若该 head 没有 CI 结论或" + "独立评审结论,请设计方手动接链(重跑 CI 或 bin/dispatch review ),评审不会自动重跑。", + file=sys.stderr) + def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) -> int: runs = state_dir(self.root) / "runs" / task.id - records = slot / "docs" / "runs" / task.path.split("/")[-1].removesuffix(".md") + folder = task.path.split("/")[-1].removesuffix(".md") + records = slot / "docs" / "runs" / folder number = len(list(records.glob("*.json"))) + 1 if records.exists() else 1 pr, ci_rounds, feedback = None, 0, "" while True: started = _now() update_slot(self.root, index, attempt=number) - attempt, prompts = self.local_rounds(task, slot, guard, feedback, runs / str(number)) + attempt, prompts = self.local_rounds(task, slot, guard, feedback, runs / str(number), pr=pr) prompt_sha = self.write_record(task, slot, number, attempt, prompts, guard_ref, started, ci_rounds) self.github.push(slot, task.branch) if not attempt.ok: self.escalate(task, pr, attempt, f"本地未完成({EXIT_TEXT.get(attempt.exit, attempt.exit)})") return 1 if pr is None: - pr = self.github.open_pr(slot, task.branch, f"{task.id}:{_title(self.root, task)}", - pr_body(task, attempt, number, prompt_sha)) + # B70:开新 PR 前先查该分支是否已有开放 PR(resume 时上一轮已开出),有则复用编号, + # 后续 CI 轮次反馈照旧注入既有 PR;查询为空或适配器没有这个查询(既有桩)才走原新建路径。 + lookup = getattr(self.github, "existing_pr", None) + if lookup is not None: + pr = lookup(task.branch) + if pr is None: + body = pr_body(task, attempt, number, prompt_sha, root=self.root) + pr = self.github.open_pr(slot, task.branch, _pr_title(self.root, task), body) + observation.push_pr(task.branch, slot, pr, f"docs/runs/{folder}/{number}.json", body) ci_rounds += 1 - ok, summary = self.github.wait_ci(task.branch, git("rev-parse", "HEAD", cwd=slot), - self.config.ci_timeout_seconds) + head = git("rev-parse", "HEAD", cwd=slot) + detail: dict = {} + alerts.last_round_alert(task.branch, task.id, ci_rounds, task.budget["ci_rounds"], pr=pr, gh=self.github) + ok, summary = self.github.wait_ci(task.branch, head, self.config.ci_timeout_seconds, detail) + observation.ci_wait(task.branch, pr, head, ci_rounds, ok, summary, detail.get("run_ids")) if ok: print(f"{task.id}:PR #{pr} 的 CI 通过,合并由 auto-merge 判定") return 0 @@ -425,11 +593,17 @@ def _loop(self, task: Task, index: int, slot: Path, guard: Path, guard_ref: str) feedback, number = summary, number + 1 def escalate(self, task: Task, pr: int | None, attempt: Attempt, reason: str) -> None: - body = escalation_body(task, attempt, reason) - if pr is not None: - self.github.comment(pr, body, label="escalation") - else: - self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + body = escalation_body(task, attempt, reason, pr=pr) + target = "pr" if pr is not None else "issue" + sent = False + try: + if pr is not None: + self.github.comment(pr, body, label="escalation") + else: + self.github.create_issue(f"升级:{task.id} {reason}", body, labels=["escalation"]) + sent = True + finally: + observation.escalate(task.branch, pr, reason, target, body, sent) # 评论失败也留事件 print(f"{task.id}:{reason},已升级") @@ -445,7 +619,24 @@ def _title(root: Path, task: Task) -> str: return first[1].splitlines()[0].removeprefix("任务:").strip() if len(first) > 1 else task.path -def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str) -> str: +def _pr_title(root: Path, task: Task) -> str: + """PR 标题:任务书标题已带「TXXX:」前缀时不再重复拼接(B65)。""" + title = _title(root, task) + return title if title.startswith(f"{task.id}:") else f"{task.id}:{title}" + + +def _manual_section(root: Path, task: Task) -> str: + """人工验收一节正文:验收表存在「人工」类证据行时给指引,否则写「无」(B57)。 + + 判定与 `bin/harness acceptance --manual` 同源(acceptance.Item.manual)。 + """ + items = acceptance.parse_spec(root / task.path, root) + if any(item.manual for item in items): + return "见任务书验收表中的人工条目(`bin/harness acceptance --manual`)。" + return "无" + + +def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str, root: Path = ROOT) -> str: usage = attempt.usage return "\n".join([ "## 任务", "", @@ -465,12 +656,12 @@ def pr_body(task: Task, attempt: Attempt, number: int, prompt_sha: str) -> str: "- CI 运行(当前 head):见本 PR checks", "- 风险等级与修复证据:见 harness job summary(机器生成)", "", "## 需要人工验收的部分", "", - "见任务书验收表中的人工条目(`bin/harness acceptance --manual`)。", "", + _manual_section(root, task), "", "🤖 Dispatched by bin/dispatch", ]) + "\n" -def escalation_body(task: Task, attempt: Attempt, reason: str) -> str: +def escalation_body(task: Task, attempt: Attempt, reason: str, pr: int | None = None) -> str: lines = [ f"### 升级:{task.id}({reason})", "", f"- **当前状态与目标差距**:任务书 `{task.path}`,分支 `{task.branch}`;退出方式:{EXIT_TEXT.get(attempt.exit, attempt.exit)}。", @@ -483,6 +674,7 @@ def escalation_body(task: Task, attempt: Attempt, reason: str) -> str: lines += ["#### 执行方的说明(`build/dispatch/escalation.md`)", "", attempt.executor_note, ""] else: lines += ["- **可选方案与推荐**、**需要决定的问题**:执行方未提供,由设计方判断。", ""] + lines += alerts.timeline_lines(task.branch, pr) # T205:trace、安全阶段摘要与时间线入口 lines += ["完整事件流在派发机器本地(git 公共目录下 `dispatch/runs/`),不入库。"] return "\n".join(lines) + "\n" @@ -491,72 +683,6 @@ def _now() -> str: return dt.datetime.now(dt.UTC).isoformat(timespec="seconds") -# ---- GitHub(推送与写操作以 Agent 身份经 bin/as-agent) ---- - -class GitHub: - def __init__(self, root: Path = ROOT): - self.root = root - self.as_agent = [str(root / "bin" / "as-agent")] - - def _run(self, argv: list[str], cwd: Path | None = None, agent: bool = False, stdin: str | None = None) -> str: - result = subprocess.run((self.as_agent if agent else []) + argv, cwd=cwd or self.root, input=stdin, - capture_output=True, text=True, check=False) - if result.returncode != 0: - raise RuntimeError(f"{' '.join(argv[:3])} 失败:{result.stderr.strip()[-500:]}") - return result.stdout.strip() - - def remote_branch_exists(self, branch: str) -> bool: - return bool(self._run(["git", "ls-remote", "--heads", "origin", branch])) - - def push(self, slot: Path, branch: str) -> None: - self._run(["git", "push", "--quiet", "-u", "origin", branch], cwd=slot, agent=True) - - # 正文一律经 stdin(--body-file -),不进命令行(规范 §2:不在引号里写 Markdown)。 - def open_pr(self, slot: Path, branch: str, title: str, body: str) -> int: - url = self._run(["gh", "pr", "create", "--base", "main", "--head", branch, "--title", title, - "--body-file", "-"], cwd=slot, agent=True, stdin=body) - return int(url.rstrip("/").rsplit("/", 1)[-1]) - - def comment(self, pr: int, body: str, label: str | None = None) -> None: - self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) - if label: - self.add_label(pr, label) - - def _ensure_label(self, label: str) -> None: - """不存在才创建;不用 --force,已有标签(含登记用标签)不被改写。""" - try: - self._run(["gh", "label", "create", label, "--color", "d93f0b"], agent=True) - except RuntimeError: - pass # 已存在 - - def add_label(self, pr: int, label: str) -> None: - self._ensure_label(label) - self._run(["gh", "pr", "edit", str(pr), "--add-label", label], agent=True) - - def create_issue(self, title: str, body: str, labels: list[str]) -> None: - for label in labels: - self._ensure_label(label) - argv = ["gh", "issue", "create", "--title", title, "--body-file", "-"] - for label in labels: - argv += ["--label", label] - self._run(argv, agent=True, stdin=body) - - def wait_ci(self, branch: str, sha: str, timeout: float) -> tuple[bool, str]: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - runs = json.loads(self._run(["gh", "run", "list", "--workflow", "build", "--branch", branch, - "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) - run = next((item for item in runs if item["headSha"] == sha), None) - if run and run["status"] == "completed": - if run["conclusion"] == "success": - return True, "" - log = subprocess.run(["gh", "run", "view", str(run["databaseId"]), "--log-failed"], cwd=self.root, - capture_output=True, text=True, check=False).stdout - return False, f"CI 未通过:{run['url']}\n\n```\n{log[-3000:]}\n```" - time.sleep(30) - return False, f"等待 CI 超过 {int(timeout / 60)} 分钟" - - # ---- 状态与停机 ---- def status(root: Path = ROOT) -> int: @@ -589,6 +715,18 @@ def stop_all(root: Path = ROOT, wait_seconds: float = 30) -> int: return 1 +def review_calibrate_command(args) -> int: + """review-calibrate 子命令:在真实历史样本上重跑独立评审并统计 TPR/TNR(不评论 PR)。""" + from engine.agents import review as review_module # review 依赖本模块,按需导入 + + target = args.output if args.output.is_absolute() else ROOT / args.output + try: + return review_module.review_calibrate(args.reviewer, review_module.CALIBRATION_SAMPLES, target) + except (TypeError, ValueError) as error: + print(f"校准样本清单有问题:{error}") + return 2 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) @@ -602,6 +740,10 @@ def main(argv: list[str] | None = None) -> int: review.add_argument("--pending", action="store_true", help="评审全部待评审且 CI 已通过的 PR,然后退出") review.add_argument("--watch", action="store_true", help="后台常驻:每隔 --interval 分钟评审一轮") review.add_argument("--interval", type=float, default=5, help="--watch 的间隔(分钟)") + cal = sub.add_parser("review-calibrate", help="评审校准:在真实历史样本上统计 TPR/TNR(不评论 PR)") + cal.add_argument("--reviewer", choices=["opencode", "pi", "codex", "claude-code"]) + cal.add_argument("--output", type=Path, default=Path("build/review/calibration-report.md"), + help="报告路径(相对仓库根,缺省 build/review/calibration-report.md)") sub.add_parser("status", help="查看槽位") stop = sub.add_parser("stop", help="停机:终止所有正在运行的执行方") stop.add_argument("--all", action="store_true", required=True) @@ -609,7 +751,7 @@ def main(argv: list[str] | None = None) -> int: if args.command == "status": return status() if args.command == "review": - import review as review_module # review 依赖本模块,按需导入 + from engine.agents import review as review_module # review 依赖本模块,按需导入 if args.watch: return review_module.watch(args.interval, args.reviewer) @@ -620,16 +762,27 @@ def main(argv: list[str] | None = None) -> int: if args.pr is None: parser.error("review 需要 PR 编号,或 --pending、--watch") return review_module.review_pr(args.pr, args.reviewer) + if args.command == "review-calibrate": + return review_calibrate_command(args) if args.command == "stop": return stop_all() if (state_dir() / "stop").exists(): print("停机标记存在(bin/dispatch stop),不派发;恢复前删除该标记") return 1 + return _run_dispatch(args) + + +def _run_dispatch(args) -> int: + """run 子命令:派发并在结束路径统一归还槽位(B77)——成功、升级、预算耗尽与异常都覆盖。""" + runner = Dispatcher(ROOT, Config.load(), GitHub(), dispatch_host.PiHost(args.model)) try: - return Dispatcher(ROOT, Config.load(), GitHub(), dispatch_host.PiHost(args.model)).run(args.taskbook, args.resume) + return runner.run(args.taskbook, args.resume) except Stop as stop: print(f"未派发:{stop}") return 2 + finally: + if runner.acquired is not None: + return_slot(ROOT, runner.config, runner.acquired, runner.github.push) if __name__ == "__main__": diff --git a/.harness/engine/agents/dispatch_host.py b/.harness/engine/agents/dispatch_host.py index 0a0dc6c..3f78891 100644 --- a/.harness/engine/agents/dispatch_host.py +++ b/.harness/engine/agents/dispatch_host.py @@ -14,6 +14,7 @@ import json import os +import re import signal import subprocess import time @@ -23,6 +24,18 @@ GUARD_DENIAL = "harness 守卫拒绝了这次操作" TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "SSH_AUTH_SOCK") +# 缺失上下文尾报告(B46 T202):提示词要求执行方在最后一条回复末尾输出 +# 「HARNESS_CONTEXT_JSON: <一行 JSON 数组>」(数据标记,不是 shell 变量)。summary 不是自由自然语言, +# 只允许与 category 对应的 C3 短 ID;ref 只允许工具 ID 或仓库相对引用(共用合同 C3)。 +CONTEXT_MARKER = "HARNESS_CONTEXT_JSON:" +CONTEXT_CATEGORIES = ("context", "tool", "spec", "other") +CONTEXT_SUMMARIES = { + "context_unavailable": "context", + "required_tool_unavailable": "tool", + "spec_unavailable": "spec", + "spec_ambiguous": "spec", + "other_missing": "other", +} @dataclass @@ -33,6 +46,13 @@ class RunResult: model: str = "" usage: dict = field(default_factory=dict) guard_denials: dict[str, int] = field(default_factory=dict) + # T202:最后一条 assistant 消息缺失报告的安全条目与状态(reported/unknown/invalid),插在 + # guard_allowed 前(T105 冻结断言要求它仍是末位字段),构造处用关键字传参。 + missing_context: list = field(default_factory=list) + missing_context_status: str = "unknown" + # T205:本轮被守卫拒绝的工具调用数(一次调用多个拒绝理由算一次),同样插在 guard_allowed 前。 + guard_denied: int = 0 + guard_allowed: int = 0 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入) def executor_env(base: dict[str, str], identity: dict[str, str], gh_config_dir: Path) -> dict[str, str]: @@ -156,3 +176,107 @@ def parse(self, events: Path) -> tuple[str, dict, dict[str, int]]: if "cost" in result: result["cost"] = round(result["cost"], 6) return model, result, dict(denials) + + def parse_context(self, events: Path) -> dict: + """从最后一条 assistant 完成消息抽取缺失上下文尾报告(B46 T202,只读数据,不执行其中命令)。 + + 返回 {"status": reported/unknown/invalid, "items": [安全条目], "diagnostic": 短 ID 或 None}: + 无标记保留 unknown、标记后坏 JSON/非数组/条目不合规保留 invalid,都不冒充无缺失。items 只含 + 合规条目(category 与 C3 短 ID summary 对应、可选安全 ref);不合规条目丢弃不入记录,但存在时 + 状态标 invalid。长文、会话正文、本机路径不进条目;诊断只留本地可见的短 ID。 + """ + text = _last_assistant_text(events) + if not text or CONTEXT_MARKER not in text: + return {"status": "unknown", "items": [], "diagnostic": "no_marker"} + payload = text.rsplit(CONTEXT_MARKER, 1)[1].split("\n", 1)[0].strip() + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + return {"status": "invalid", "items": [], "diagnostic": "bad_json"} + if not isinstance(parsed, list): + return {"status": "invalid", "items": [], "diagnostic": "not_array"} + items, invalid = [], False + for entry in parsed: + item = _context_item(entry) + if item is None: + invalid = True + else: + items.append(item) + return {"status": "invalid" if invalid else "reported", "items": items, + "diagnostic": "invalid_entry" if invalid else None} + + def parse_observability(self, events: Path) -> dict: + """观察计数(B46 T105):按每个 tool_execution_end 计一次完成——结果带守卫拒绝标记的计 + guard_denied(一次调用一次,拒绝理由条数不冒充调用数),其余(成功或普通失败)计 + guard_allowed(守卫放行,不代表工具成功)。missing_context 取 parse_context 的安全条目 + (B46 T202);返回恰此 3 键。""" + counts = {"guard_allowed": 0, "guard_denied": 0, + "missing_context": self.parse_context(events)["items"]} + if not events.exists(): + return counts + for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") != "tool_execution_end": + continue + content = (event.get("result") or {}).get("content") or [] + text = "\n".join(part.get("text", "") for part in content if isinstance(part, dict)) + counts["guard_denied" if GUARD_DENIAL in text else "guard_allowed"] += 1 + return counts + + +def _last_assistant_text(events: Path) -> str | None: + """流中最后一条 assistant 完成消息的正文(text 部分拼接);流缺失或无 assistant 消息返回 None。 + + 只认 message_end 携带的最终权威消息,工具输出(tool_execution_end)不参与,伪造标记不采信。 + """ + if not events.exists(): + return None + text = None + for line in events.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + message = event.get("message") if event.get("type") == "message_end" else None + if isinstance(message, dict) and message.get("role") == "assistant": + text = _message_text(message) + return text + + +def _message_text(message: dict) -> str: + """assistant 消息正文:content 为字符串时原样,为数组时拼接 text 部分(其余部分如 thinking 不算)。""" + content = message.get("content") + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + return "\n".join(part.get("text", "") for part in content + if isinstance(part, dict) and part.get("type") == "text") + + +def _context_item(entry) -> dict | None: + """一条报告条目:只允许 category/summary/ref 三个键,summary 是与 category 对应的 C3 短 ID, + ref 可选且须为工具 ID 或安全相对引用;任一不满足返回 None(条目丢弃、报告标 invalid)。""" + if not isinstance(entry, dict) or set(entry) - {"category", "summary", "ref"}: + return None + category, summary, ref = entry.get("category"), entry.get("summary"), entry.get("ref") + if CONTEXT_SUMMARIES.get(summary) != category: + return None + item = {"category": category, "summary": summary} + if ref is not None: + if not _safe_context_ref(ref): + return None + item["ref"] = ref + return item + + +def _safe_context_ref(ref) -> bool: + """ref 只允许工具 ID 或仓库相对引用:≤120 字符、无换行/反斜杠、非绝对路径、无盘符、无 .. 穿越。""" + if not isinstance(ref, str) or not ref or len(ref) > 120 or "\n" in ref or "\r" in ref: + return False + if "\\" in ref or ref.startswith(("/", "~")) or re.match(r"(?i)^[a-z]:", ref): + return False + return ".." not in ref.split("/") diff --git a/.harness/engine/agents/dispatch_observation.py b/.harness/engine/agents/dispatch_observation.py new file mode 100644 index 0000000..77f5fc1 --- /dev/null +++ b/.harness/engine/agents/dispatch_observation.py @@ -0,0 +1,349 @@ +"""派发与独立评审的观察旁路(B46 T105):组装设计 3.2 / 3.4 的事件元数据并写事件。 + +共用合同 C0/C1 与任务书 F1:观察是旁路——本模块的任何失败(事件写入、引用与产物准备、摘要组装) +都不得改变派发与评审的判定、返回码或 host/gh 调用序列;dispatch.py 与 review.py 只接线。事件里只有 +引用、哈希、计数、枚举、时长(隐私过滤由 events.emit 负责);每轮 Pi 流、verify 输出、CI 失败摘要、 +升级摘要与评审材料等原始内容只存本机产物(artifacts/),事件与审计摘要只带哈希与大小。 +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +from pathlib import Path + +from engine.core import events +from engine.core.common import git + +STAGE = "dispatch" +REVIEW_STAGE = "review" +# C6 严重度词表 → 事件输出的 ASCII 键(outputs 键只允许 [A-Za-z0-9_.-],中文键会被过滤器丢弃)。 +SEVERITIES = ("阻断", "严重", "一般", "建议") +_SEVERITY_KEYS = {"阻断": "severity.blocker", "严重": "severity.critical", "一般": "severity.major", + "建议": "severity.minor", "unknown": "severity.unknown"} + + +def _emit(stage: str, step: str, status: str, **kwargs) -> None: + """写事件并吞掉一切失败:emit 自身不抛,组装 kwargs 的异常也不能逃出观察旁路。""" + try: + events.emit(stage=stage, step=step, status=status, **kwargs) + except Exception: # noqa: BLE001 观察旁路:失败只丢弃 + return + + +def _artifact_refs(prefix: str, data: bytes) -> dict: + """本机产物引用三元字段(.sha256/.size/.ref);内容只存本机,事件只有哈希与大小。""" + refs = {f"{prefix}.sha256": hashlib.sha256(data).hexdigest(), f"{prefix}.size": len(data)} + try: + stored = events.store_artifact(data) + except Exception: # noqa: BLE001 观察旁路:产物保存失败只少记 ref + stored = None + if stored: + refs[f"{prefix}.ref"] = stored["ref"] + return refs + + +# ---- 派发(设计 3.2)---- + +def admit(rel: str, root: Path, task=None, problems: list[str] | None = None) -> None: + """1 准入:任务书 sha 与 origin/main 引用;通过与否、任务编号、类别与预算(时长/重试/CI 轮次)。""" + try: + if not events.enabled(): + return + inputs = _admit_inputs(rel, root) + if task is None: + reason = problems[0] if problems else None + _emit(STAGE, "admit", "fail", inputs=inputs, outputs={"problems": len(problems or [])}, + decision={"by": "taskbook", "rule": "admit", "reason": reason}, error={"kind": "admit"}) + return + budget = task.budget or {} + _emit(STAGE, "admit", "ok", trace_id=task.branch, inputs=inputs, + outputs={"task": task.id, "class": task.klass, "wall_clock_min": budget.get("wall_clock_min"), + "retries": budget.get("retries"), "ci_rounds": budget.get("ci_rounds")}, + decision={"by": "taskbook", "rule": "admit"}) + except Exception: # noqa: BLE001 观察旁路 + return + + +def _admit_inputs(rel: str, root: Path) -> list[dict]: + """任务书(路径@origin/main + 内容哈希)与 origin/main 提交引用;读取失败只少记。""" + inputs: list[dict] = [] + try: + rev = git("rev-parse", "origin/main", cwd=root, check=False) + data = (root / rel).read_bytes() + except Exception: # noqa: BLE001 观察旁路 + return inputs + if data: + inputs.append({"kind": "taskbook", "ref": f"{rel}@{rev}" if rev else rel, + "sha256": hashlib.sha256(data).hexdigest(), "size": len(data)}) + if rev: + inputs.append(events.ref("rev", rev)) + return inputs + + +def claim(branch: str, claimed: bool) -> None: + """2 认领:远端分支已存在为 fail(已被认领);从槽位推送新分支成功为 ok。""" + _emit(STAGE, "claim", "ok" if claimed else "fail", trace_id=branch, + inputs=[events.ref("branch", branch)], outputs={"claimed": claimed}, + decision=None if claimed else {"by": "dispatch", "rule": "claim", "reason": "已被认领"}) + + +def slot(branch: str, index: int | None, start: str | None = None, base: str | None = None, + problem: str | None = None) -> None: + """3 槽位:槽位序号与起点提交;无空闲槽位等失败也留事件。""" + outputs: dict = {"slot": index, "start": start} + if problem: + outputs["problem"] = problem + _emit(STAGE, "slot", "fail" if problem else "ok", trace_id=branch, + inputs=[events.ref("rev", base)] if base else None, outputs=outputs) + + +def guard_preflight(branch: str, guard_ref: str, denied: bool) -> None: + """4 守卫预检:必拒载荷实际被拒为 ok;导出或探针失败为 fail。""" + _emit(STAGE, "guard_preflight", "ok" if denied else "fail", trace_id=branch, + inputs=[events.ref("rev", guard_ref)] if guard_ref else None, outputs={"denied": denied}) + + +def tool_counts(host, stream: Path) -> dict: + """执行方流的工具调用计数(每次 tool_execution_end 计一次完成):守卫拒绝计 guard_denied, + 其余(成功或普通失败)计 guard_allowed。宿主缺 parse_observability 或其失败时按 0 计(观察旁路)。""" + try: + observed = host.parse_observability(stream) + return {"guard_allowed": int(observed.get("guard_allowed", 0)), + "guard_denied": int(observed.get("guard_denied", 0))} + except Exception: # noqa: BLE001 观察旁路 + return {"guard_allowed": 0, "guard_denied": 0} + + +def executor_round(branch: str, slot: Path, prompt: str, stream: Path, result, + host: dict | None = None, counts: dict | None = None) -> None: + """5 执行方每轮:宿主与版本、模型、时长、退出原因、token/费用、守卫放行与被拒工具数、 + 完整事件流的本机产物哈希。""" + try: + if not events.enabled(): + return + base = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = [events.ref("prompt", hashlib.sha256(prompt.encode("utf-8")).hexdigest())] + if base: + inputs.append(events.ref("rev", base)) + usage = result.usage or {} + outputs = {"host": (host or {}).get("name"), "host_version": (host or {}).get("version"), + "model": result.model or None, "exit": result.exit, + "gen_ai.usage.input_tokens": usage.get("input_tokens"), + "gen_ai.usage.output_tokens": usage.get("output_tokens"), "cost": usage.get("cost"), + "guard_allowed": result.guard_allowed, + "guard_denied": (counts or {}).get("guard_denied"), **_stream_refs(stream)} + _emit(STAGE, "executor_round", "ok" if result.exit == "ok" else "fail", trace_id=branch, + duration_ms=int(result.seconds * 1000), inputs=inputs, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def _stream_refs(stream: Path) -> dict: + """完整执行方流(原始字节)的本机产物引用;读取失败只少记三元字段。""" + try: + return _artifact_refs("stream", stream.read_bytes()) + except Exception: # noqa: BLE001 观察旁路 + return {} + + +def local_verify(branch: str, slot: Path, base: str, ok: bool, signature: str, log: str, + repeat: bool) -> None: + """6 本地判定每轮:通过与否、失败签名、是否打转(逐项检查结果走 verify 自己的事件); + verify 完整输出保存为本机产物,事件只有哈希与大小。""" + try: + if not events.enabled(): + return + head = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = [events.ref("rev", rev) for rev in (base, head) if rev] + outputs: dict = {"ok": ok, "signature": signature or None, "repeat": repeat} + if log: + outputs.update(_artifact_refs("log", log.encode("utf-8"))) + _emit(STAGE, "local_verify", "ok" if ok else "fail", trace_id=branch, inputs=inputs, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def clarify(branch: str, note: Path, context: dict | None = None) -> None: + """执行方请求澄清:备注 sha256 与是否请求澄清;context 是 T202 的解析结果,只把安全条目的 + 计数与类别计数写进事件(与运行记录 missing_context 同源;无条目时与原形状一致)。""" + try: + if not events.enabled(): + return + data = note.read_bytes() + except Exception: # noqa: BLE001 观察旁路 + return + items = (context or {}).get("items") or [] + outputs: dict = {"requested": True, "missing_context": len(items)} + per_category: dict = {} + for item in items: + if isinstance(item, dict) and isinstance(item.get("category"), str): + per_category[item["category"]] = per_category.get(item["category"], 0) + 1 + outputs.update({f"missing_context.{category}": count + for category, count in sorted(per_category.items())}) + _emit(STAGE, "clarify", "ok", trace_id=branch, + inputs=[{"kind": "note", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}], + outputs=outputs) + + +def push_pr(branch: str, slot: Path, pr: int, record_path: str, body: str) -> None: + """7 推送与开 PR(PR 实际开出后调用):运行记录 path@提交、PR 正文哈希、提交 SHA 与 PR 号。""" + try: + if not events.enabled(): + return + head = git("rev-parse", "HEAD", cwd=slot, check=False) or None + inputs = ([{"kind": "run_record", "ref": f"{record_path}@{head}"}] if head and record_path else []) + data = body.encode("utf-8") + if body: + inputs.append({"kind": "pr_body", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}) + outputs: dict = {"head": head, "pr": pr} + if body: + outputs.update(_artifact_refs("body", data)) + _emit(STAGE, "push_pr", "ok", trace_id=branch, inputs=inputs or None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def ci_wait(branch: str, pr: int, head: str, round_no: int, ok: bool, summary: str, + run_ids: list | None = None) -> None: + """8 等 CI 每轮:轮次、结论、Actions 运行 ID 与失败摘要哈希。""" + try: + if not events.enabled(): + return + outputs: dict = {"pr": pr, "round": round_no, "ok": ok, + "run_ids": ",".join(str(value) for value in run_ids or [])} + if summary: + outputs.update(_artifact_refs("summary", summary.encode("utf-8"))) + _emit(STAGE, "ci_wait", "ok" if ok else "fail", trace_id=branch, + inputs=[events.ref("rev", head)] if head else None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +def escalate(branch: str, pr: int | None, reason: str, target: str, summary: str, sent: bool) -> None: + """升级:原因、目标(pr 评论或 issue)与标签;状态摘要只留本机产物哈希。评论失败也留事件(fail)。""" + try: + if not events.enabled(): + return + inputs = [events.ref("pr", str(pr))] if pr is not None else [] + data = summary.encode("utf-8") + if summary: + inputs.append({"kind": "summary", "ref": hashlib.sha256(data).hexdigest(), "size": len(data)}) + outputs: dict = {"reason": reason, "target": target, "pr": pr, "labels": "escalation", + "notified": sent} + if summary: + outputs.update(_artifact_refs("summary", data)) + _emit(STAGE, "escalate", "ok" if sent else "fail", trace_id=branch, + inputs=inputs or None, outputs=outputs) + except Exception: # noqa: BLE001 观察旁路 + return + + +# ---- 独立评审(设计 3.4 与共用合同 C6)---- + +def severity_counts(findings: list | None) -> dict: + """C6 severity_counts:阻断/严重/一般/建议/unknown 计数;缺失或未知严重度计入 unknown。""" + counts: dict = {key: 0 for key in SEVERITIES} + counts["unknown"] = 0 + for item in findings or []: + severity = item.get("severity") if isinstance(item, dict) else None + counts[severity if severity in counts else "unknown"] += 1 + return counts + + +def _engine_ref(workspace: Path) -> str: + """生成 pack 的引擎固定提交:业务仓库读 engine.lock 的 commit,否则用工作区 HEAD。""" + with contextlib.suppress(Exception): # 观察旁路:锁文件缺失或损坏时回退 HEAD + lock = json.loads((workspace / ".harness" / "engine.lock").read_text(encoding="utf-8")) + commit = str(lock.get("commit") or "").strip() + if commit: + return commit + return git("rev-parse", "HEAD", cwd=workspace, check=False) or "unknown" + + +def _material(kind: str, path: Path, ref: str, recipe: dict, *, encoding: str = "utf8", + raw_artifact: bool = False) -> dict | None: + """一份 C6 材料:评审实际读取文件的原始字节哈希;pack 原始字节转存本机产物(不上传)。 + + ref 是引用(路径@提交、比较范围、pull 引用或 none 哨兵);pack 的 ref 是内容寻址产物 sha256。 + 读取失败只少记这一份(观察旁路)。 + """ + try: + data = path.read_bytes() + except OSError: + return None + digest = hashlib.sha256(data).hexdigest() + entry = {"kind": kind, "ref": digest if raw_artifact else ref, "sha256": digest, + "size": len(data), "encoding": encoding, "recipe": recipe} + if raw_artifact: + entry["recipe"]["artifact_sha256"] = digest + with contextlib.suppress(Exception): # 转存失败不改变材料哈希 + events.store_artifact(data) + return entry + + +def review_materials(workspace: Path, base: str, head: str, taskbook_rel: str | None, + pr: int) -> list[dict]: + """C6 materials:task/diff/ci/pr/pack 五类,recipe 用固定短 ID 与参数,不存命令正文。 + + 无任务书时 task 材料是现有的「无」哨兵字节,ref 用 none 哨兵;单份材料读取失败只少记这一份。 + diff 按分片集合读取(T124:build/review/ 下全部 diff*.patch,与 pack 材料清单同一口径): + 小 diff 仍是单份材料,分片时每片各记一份、引用同一比较范围。 + """ + folder = workspace / "build" / "review" + diffs = sorted(folder.glob("diff*.patch")) if folder.is_dir() else [] + materials = [ + _material("task", folder / "task.md", f"{taskbook_rel}@{head}" if taskbook_rel else "none", + {"id": "task_v1"}), + *(_material("diff", path, f"{base}...{head}", {"id": "diff_v1"}) for path in diffs), + _material("ci", folder / "ci.md", f"pull/{pr}/checks", {"id": "ci_v1"}), + _material("pr", folder / "pr.md", f"pull/{pr}", {"id": "pr_v1"}), + _material("pack", folder / "pack.md", "", {"id": "pack_v1", "engine_ref": _engine_ref(workspace), + "retention_days": 30}, + encoding="raw_bytes", raw_artifact=True), + ] + return [item for item in materials if item] + + +def review_audit(*, trace_id: str, head: str, base: str, reviewer: str, model, model_basis: str, + designer, implementers: list | None, independent, same_host: bool, parsed: bool, + verdict: str, duration_ms: int, findings: list | None, + materials: list | None) -> dict: + """C6 harness-review-audit 摘要(单行 JSON,嵌在评论标记里):不含评论自身哈希与尚未知的 URL。""" + return { + "schema_version": 1, "trace_id": trace_id, "head": head, "base": base, + "reviewer": reviewer, "model": model, "model_basis": model_basis, "designer": designer, + "implementers": list(implementers or []), "independent": independent, "same_host": same_host, + "parsed": parsed, "verdict": verdict, "duration_ms": duration_ms, + "severity_counts": severity_counts(findings), "materials": list(materials or []), + } + + +def review(*, trace: str, head: str, reviewer: str, verdict: str, duration_ms: int, + findings: list | None, materials: list | None, designer=None, model=None, + model_basis: str = "unknown", parsed: bool = True, independent=None, same_host: bool = False, + comment: str | None = None, comment_body: str = "", failure: str = "", + error_kind: str = "") -> None: + """评审观察事件:材料引用、评审方与模型、时长、结论、按严重度计数、身份是否分离、评论 URL 与 + 评论字节哈希(评论发布后);评审方自身失败记 error.kind(status error),结论不通过记 status fail。""" + try: + if not events.enabled(): + return + inputs = ([events.ref("rev", head)] if head else []) + [ + {"kind": item["kind"], "ref": item["ref"], "sha256": item["sha256"], "size": item["size"]} + for item in materials or []] + outputs: dict = {"reviewer": reviewer, "model": model, "verdict": verdict, "parsed": parsed, + "designer": designer, "independent": independent, "same_host": same_host, + **{_SEVERITY_KEYS[key]: count for key, count in severity_counts(findings).items()}} + if failure: + outputs["failure"] = failure + if comment: + outputs["comment.url"] = comment + if comment_body: + outputs.update(_artifact_refs("comment", comment_body.encode("utf-8"))) + status = "error" if error_kind else ("fail" if verdict == "不通过" else "ok") + _emit(REVIEW_STAGE, "review", status, trace_id=trace or None, duration_ms=duration_ms, + inputs=inputs or None, outputs=outputs, error={"kind": error_kind} if error_kind else None) + except Exception: # noqa: BLE001 观察旁路 + return diff --git a/.harness/engine/agents/github.py b/.harness/engine/agents/github.py new file mode 100644 index 0000000..cc8162b --- /dev/null +++ b/.harness/engine/agents/github.py @@ -0,0 +1,149 @@ +"""GitHub 封装:推送与写操作以 Agent 身份经 bin/as-agent(T123 自 dispatch.py 逐字抽离,行为不变)。""" + +from __future__ import annotations + +import json +import subprocess +import time +from pathlib import Path + +from engine.core.common import ROOT, ci_workflows + +CI_QUERY_ATTEMPTS = 3 # 等 CI 时对 gh 查询的最多尝试次数 +CI_QUERY_RETRY_SECONDS = 5 + + +# ---- GitHub(推送与写操作以 Agent 身份经 bin/as-agent) ---- + +class GitHub: + def __init__(self, root: Path = ROOT): + self.root = root + self.as_agent = [str(root / "bin" / "as-agent")] + + def _run(self, argv: list[str], cwd: Path | None = None, agent: bool = False, stdin: str | None = None) -> str: + result = subprocess.run((self.as_agent if agent else []) + argv, cwd=cwd or self.root, input=stdin, + capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(f"{' '.join(argv[:3])} 失败:{result.stderr.strip()[-500:]}") + return result.stdout.strip() + + def remote_branch_exists(self, branch: str) -> bool: + return bool(self._run(["git", "ls-remote", "--heads", "origin", branch])) + + def push(self, slot: Path, branch: str) -> None: + self._run(["git", "push", "--quiet", "-u", "origin", branch], cwd=slot, agent=True) + + # 正文一律经 stdin(--body-file -),不进命令行(规范 §2:不在引号里写 Markdown)。 + def open_pr(self, slot: Path, branch: str, title: str, body: str) -> int: + url = self._run(["gh", "pr", "create", "--base", "main", "--head", branch, "--title", title, + "--body-file", "-"], cwd=slot, agent=True, stdin=body) + return int(url.rstrip("/").rsplit("/", 1)[-1]) + + def existing_pr(self, branch: str) -> int | None: + """分支已有的开放 PR 编号,没有则 None(B70):开新 PR 前查询,已有则复用编号不再新建。 + 查询失败(如网络断开)按「无既有 PR」处理,保留原新建路径。""" + try: + raw = self._run(["gh", "pr", "list", "--head", branch, "--state", "open", + "--json", "number", "--limit", "1"]) + return int(json.loads(raw or "[]")[0]["number"]) + except (RuntimeError, TypeError, ValueError, KeyError, IndexError): + return None + + def comment(self, pr: int, body: str, label: str | None = None) -> str: + """评论 PR 并返回评论 URL(供观察事件记录;标签逻辑不变)。""" + url = self._run(["gh", "pr", "comment", str(pr), "--body-file", "-"], agent=True, stdin=body) + if label: + self.add_label(pr, label) + return url + + def _ensure_label(self, label: str) -> None: + """不存在才创建;不用 --force,已有标签(含登记用标签)不被改写。""" + try: + self._run(["gh", "label", "create", label, "--color", "d93f0b"], agent=True) + except RuntimeError: + pass # 已存在 + + def add_label(self, pr: int, label: str) -> None: + self._ensure_label(label) + self._run(["gh", "pr", "edit", str(pr), "--add-label", label], agent=True) + + def create_issue(self, title: str, body: str, labels: list[str]) -> None: + for label in labels: + self._ensure_label(label) + argv = ["gh", "issue", "create", "--title", title, "--body-file", "-"] + for label in labels: + argv += ["--label", label] + self._run(argv, agent=True, stdin=body) + + def list_comments(self, pr: int) -> list[dict]: + """分页列出 PR 评论(id 与正文),供告警远端标记去重(B46 T205)。""" + raw = self._run(["gh", "api", f"repos/{{owner}}/{{repo}}/issues/{pr}/comments", "--paginate", "--slurp"]) + return [item for page in json.loads(raw or "[]") for item in page] + + def edit_comment(self, comment_id: int, body: str) -> None: + self._run(["gh", "api", "-X", "PATCH", f"repos/{{owner}}/{{repo}}/issues/comments/{comment_id}", + "--input", "-"], agent=True, stdin=json.dumps({"body": body})) + + def list_issues(self, label: str) -> list[dict]: + """带标签的全部议题(编号与正文,最多 200 条):告警无 PR 时按远端标记查升级议题。""" + raw = self._run(["gh", "issue", "list", "--state", "all", "--label", label, "--limit", "200", + "--json", "number,body"]) + return json.loads(raw or "[]") + + def edit_issue(self, number: int, body: str) -> None: + self._run(["gh", "issue", "edit", str(number), "--body-file", "-"], agent=True, stdin=body) + + def _ci_runs(self, workflow: str, branch: str) -> list[dict]: + """查一个工作流在该分支上的运行。gh 瞬时失败(如网络断开)连续 CI_QUERY_ATTEMPTS 次才抛出,避免一次瞬断中止整个派发。""" + for attempt in range(1, CI_QUERY_ATTEMPTS + 1): + try: + return json.loads(self._run(["gh", "run", "list", "--workflow", workflow, "--branch", branch, + "--json", "headSha,status,conclusion,databaseId,url", "--limit", "10"])) + except (RuntimeError, json.JSONDecodeError): + if attempt == CI_QUERY_ATTEMPTS: + raise + time.sleep(CI_QUERY_RETRY_SECONDS) + return [] + + def _completed_runs(self, workflows: list[str], branch: str, sha: str) -> dict[str, dict]: + """一轮轮询:每个工作流各查一次,返回该提交上已完结的工作流 → 运行;查询失败原样抛出。""" + found = {} + for workflow in workflows: + runs = self._ci_runs(workflow, branch) + run = next((item for item in runs if item["headSha"] == sha), None) + if run and run["status"] == "completed": + found[workflow] = run + return found + + def wait_ci(self, branch: str, sha: str, timeout: float, detail: dict | None = None) -> tuple[bool, str]: + """等 rules.toml [dispatch] ci_workflows 列出的每个工作流在该提交上跑完;全部成功才算通过。 + + detail 非 None 时写入 run_ids(本次结论对应的 Actions 运行 ID),供派发的 ci_wait 观察事件 + 使用;返回值与 gh 调用序列保持不变。查询重试耗尽仍失败时按「CI 结果未知」返回,不向上抛 + (B71):异常出 wait_ci 会崩掉整个派发进程,摘要带已推 head 与失败摘要,随派发既有升级 + 通道(PR 评论或议题)留痕,由人重跑或补查。 + """ + workflows = ci_workflows() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + found = self._completed_runs(workflows, branch, sha) + except (RuntimeError, json.JSONDecodeError) as error: # B71:重试耗尽仍失败,CI 结果未知;不再上抛崩派发进程 + if detail is not None: + detail["run_ids"] = [] + return False, f"CI 结果未知(已推 head {sha}):{error}" + failed = next((run for run in found.values() if run["conclusion"] != "success"), None) + if failed: + log = subprocess.run(["gh", "run", "view", str(failed["databaseId"]), "--log-failed"], cwd=self.root, + capture_output=True, text=True, check=False).stdout + if detail is not None: + detail["run_ids"] = [failed["databaseId"]] + return False, f"CI 未通过:{failed['url']}\n\n```\n{log[-3000:]}\n```" + if len(found) == len(workflows): + if detail is not None: + detail["run_ids"] = sorted(run["databaseId"] for run in found.values()) + return True, "" + time.sleep(30) + if detail is not None: + detail["run_ids"] = [] + return False, f"等待 CI 超过 {int(timeout / 60)} 分钟" diff --git a/.harness/engine/agents/plan_review.py b/.harness/engine/agents/plan_review.py new file mode 100644 index 0000000..5fbc78a --- /dev/null +++ b/.harness/engine/agents/plan_review.py @@ -0,0 +1,160 @@ +"""任务拆分评审(B54):把设计/计划文档与其拆分材料交给独立评审方,按 docs/task-splitting.md 的拆分评审 +检查项(完整性、正确性、可验证、可完成、一致性)评审拆分能否一并提交;报告只写本地 markdown, +不评论任何 GitHub PR。 + +入口经 engine/cli.py 的 COMMANDS 注册表走既有通用路由:python3 <引擎目录>/cli.py review-plan +<设计文档路径> [--output 路径](报告缺省 build/review/plan-verdict.md,相对仓库根解析)。 +材料以指定文档为主,自动收集它正文链接到的 docs 下本地 markdown(含追溯表、共用合同)与 +docs/plans/ 下全部任务书(含未被正文链接的);外链、仓库外与非 docs 目标不算材料。复用 +engine/agents/review.py 的材料组装(write_materials 同格式落盘)与评审执行(run_reviewer、 +parse_output)在只读评审工作区运行;结论(含 findings)、材料清单与缺失清单写入报告。设计文档 +不存在时不评审,写缺失报告并返回 1;链接目标缺失进缺失清单并在报告中标注,不静默跳过。 +""" + +from __future__ import annotations + +import argparse +import os +import re +from pathlib import Path + +from engine.agents.review import ( + Reviewer, + Verdict, + _cell, + checkout, + make_reviewer, + review_workspace, + run_reviewer, + write_materials, +) +from engine.core.common import ROOT, git, load_rules + +PLAN_LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") + + +def _plan_doc_links(plan_text: str, plan_dir: Path, root: Path) -> tuple[list[str], list[str]]: + """plan 正文里链接到的 docs 下本地 markdown(docs/plans/*.md、docs/*.md 与 docs 其余子目录)。 + 返回 (存在的仓库相对路径, 缺失的链接目标),各自排序去重;外链、仓库外与 docs 外的目标不是材料。""" + found, missing = [], [] + for target in dict.fromkeys(PLAN_LINK.findall(plan_text)): + path = target.split("#", 1)[0] + if not path or "://" in path: + continue + try: + rel = Path(os.path.normpath(plan_dir / path)).relative_to(root).as_posix() + except ValueError: + continue # 解析到仓库外 + if rel.split("/", 1)[0] == "docs" and rel.endswith(".md"): + (found if (root / rel).exists() else missing).append(rel) + return sorted(set(found)), sorted(set(missing)) + + +def collect_plan_materials(plan_doc: Path, root: Path) -> tuple[str, str, list[tuple[str, str]], list[str]]: + """B54 拆分评审材料:以 plan 文档为主材料,收集其正文链接到的 docs 下本地 markdown,加上 docs/plans/ 下 + 全部任务书(含未被正文链接的)。返回 (plan 相对路径, plan 全文, [(仓库相对路径, 全文)], 缺失的链接目标); + plan 文档不在仓库内或读不到时,前两项为空串、文档本身进缺失清单(不静默跳过)。""" + try: + plan_rel = plan_doc.relative_to(root).as_posix() + plan_text = plan_doc.read_text(encoding="utf-8") + except (ValueError, OSError): + try: + name = plan_doc.relative_to(root).as_posix() + except ValueError: + name = plan_doc.as_posix() + return "", "", [], [name] + linked, missing = _plan_doc_links(plan_text, plan_doc.parent, root) + taskbooks = [path.relative_to(root).as_posix() for path in sorted((root / "docs" / "plans").glob("task-*.md"))] + return plan_rel, plan_text, [(rel, (root / rel).read_text(encoding="utf-8")) + for rel in sorted(set(linked) | set(taskbooks))], missing + + +def render_plan_report(plan_rel: str, materials: list[tuple[str, str]], missing: list[str], verdict: Verdict, + reviewer: str, model: str, seconds: float) -> str: + """拆分评审报告:结论(含 findings)、材料清单与缺失清单(有缺失时在报告中标注)。""" + lines = [ + f"# 拆分评审报告:{plan_rel}", "", + f"- 评审方:{reviewer}({model or '模型未报告'});用时 {seconds / 60:.1f} 分钟", + f"- 材料清单({len(materials)} 份):", + *(f" - `{rel}`" for rel, _ in materials), + f"- 缺失清单:{'、'.join(f'`{item}`' for item in missing) or '无'}", + "", f"## 结论:{verdict.verdict}", "", + verdict.summary or "(无)", "", + ] + if verdict.findings: + lines += ["| 严重度 | 位置 | 问题 | 修复要求 |", "|---|---|---|---|"] + lines += [f"| {item.get('severity', '')} | {item.get('location', '')} | {_cell(item.get('problem', ''))} | " + f"{_cell(item.get('fix', ''))} |" for item in verdict.findings] + elif not verdict.failure: + lines.append("没有发现。") + if verdict.failure: + lines += ["", f"评审方失败:{verdict.failure}"] + if missing: + lines += ["", "注意:缺失清单中的链接目标未参与本次评审。"] + return "\n".join(lines) + "\n" + + +def review_plan(plan_doc: Path, output: Path, *, root: Path = ROOT, reviewer: Reviewer | None = None) -> int: + """B54 任务拆分评审:以设计/计划文档为主材料,自动收集其链接的 docs 本地材料与全部任务书,复用独立评审的 + 材料组装(write_materials 同格式落盘)与评审执行(run_reviewer、parse_output)在只读评审工作区评审;结论 + (含 findings)与材料清单写入 output(markdown),不评论任何 GitHub PR。设计文档或链接目标缺失时进缺失 + 清单并在报告中标注;文档本身缺失时不评审,写缺失报告并返回 1。""" + name = load_rules().get("review", {}).get("reviewer") + runner = reviewer or (make_reviewer(name) if name else None) + if runner is None: + print("未能判定评审方:在 rules.toml [review] 配置 reviewer") + return 2 + plan_doc = plan_doc if plan_doc.is_absolute() else root / plan_doc + output = output if output.is_absolute() else root / output + plan_rel, plan_text, materials, missing = collect_plan_materials(plan_doc, root) + if not plan_rel: # 设计文档不存在或不在仓库内:写缺失报告,不评审 + report = render_plan_report(missing[0], [], missing, + Verdict("需用户验收", [], "设计文档不存在,未评审", parsed=False), + runner.name, "", 0.0) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(report, encoding="utf-8") + print(f"拆分评审:设计文档缺失({missing[0]}),报告已写入 {output}") + return 1 + workspace = review_workspace(root) + git("fetch", "--quiet", "origin", "main", cwd=workspace) + checkout(workspace, "origin/main") + base = git("rev-parse", "origin/main", cwd=workspace) + folder = workspace / "build" / "review" / "materials" + folder.mkdir(parents=True, exist_ok=True) + for rel, text in materials: # 材料全文按仓库相对路径落盘,评审方在只读沙箱里读得到 + target = folder / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + listing = "\n".join(f"- `{rel}`" for rel, _ in materials) or "-(没有收集到任何材料)" + gone = "\n".join(f"- `{item}`" for item in missing) or "-(无)" + pr_text = ( + f"# 拆分评审:{plan_rel}\n\n" + "这不是 PR 评审,而是任务拆分评审:对照 docs/task-splitting.md 的拆分评审检查项(完整性、正确性、" + "可验证、可完成、一致性),依据下面的主材料与关联材料评审这份拆分能否一并提交。\n\n" + f"## 主材料:{plan_rel} 全文\n\n{plan_text}\n\n" + f"## 关联材料({len(materials)} 份,全文在 build/review/materials/ 下按仓库相对路径存放)\n\n{listing}\n\n" + f"## 缺失的链接目标(未参与评审)\n\n{gone}\n" + ) + write_materials(workspace, base, pr_text, "", "(拆分评审没有 CI 运行)") + verdict, model, seconds = run_reviewer(runner, workspace, load_rules().get("review", {}).get( + "timeout_minutes", 30) * 60) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(render_plan_report(plan_rel, materials, missing, verdict, runner.name, model, seconds), + encoding="utf-8") + if verdict.failure: + print(f"拆分评审:评审方失败({verdict.failure}),报告已写入 {output}") + return 1 + print(f"拆分评审:{verdict.verdict}({runner.name}),报告已写入 {output}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """review-plan 子命令(engine/cli.py COMMANDS 注册表路由):任务拆分评审,报告写本地 markdown。""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("plan_doc", type=Path, help="设计/计划文档路径(相对仓库根)") + parser.add_argument("--output", type=Path, default=Path("build/review/plan-verdict.md"), + help="报告路径(相对仓库根,缺省 build/review/plan-verdict.md)") + args = parser.parse_args(argv) + doc = args.plan_doc if args.plan_doc.is_absolute() else ROOT / args.plan_doc + out = args.output if args.output.is_absolute() else ROOT / args.output + return review_plan(doc, out) diff --git a/.harness/engine/agents/review.py b/.harness/engine/agents/review.py index 09dcc93..b20ea4c 100644 --- a/.harness/engine/agents/review.py +++ b/.harness/engine/agents/review.py @@ -1,7 +1,9 @@ """独立评审:R2 及以上的 PR 由非任务设计方的 Agent 评审,结论写回 PR 供用户参考(目标态设计 8.3,决定 5)。 分离规则:评审方不是该任务的设计方(任务书头部 designer;没有任务书时按提交的 Co-Authored-By 判断); -也不看执行方轨迹,只看任务书、PR 描述、diff 与证据包。默认评审方与各评审方的模型由 rules.toml [review] +也不看执行方轨迹,只看任务书、PR 描述、diff 与证据包。材料里的任务书在既有探测(run_check.scope) +未命中时,从 PR 标题与正文的 `docs/plans/task-*.md` 引用回退(B68),探测命中时行为不变。 +默认评审方与各评审方的模型由 rules.toml [review] 指定(不配模型时沿用评审方工具自己的默认设置);评审方与执行方同一宿主时在评论中标出,供用户判断独立性。 结构上保证只读:评审在单独的 worktree(<仓库名>-review)里进行,Pi 只开放 read、grep、find、ls 且不加载 @@ -9,10 +11,13 @@ 凭据,结论由本脚本以 Agent 身份评论到 PR。 试行期内评审结论只是给用户的输入,不替代用户审批。 -校准(设计 8.3):`calibrate` 用已知有问题的改动(由事故回放用例在 main 上重新注入)与已知良好的已合并 PR, -分别统计抓住问题的比例(TPR)与放过好改动的比例(TNR),结果写入 evals/review/results/。 +校准(设计 8.3)有两个入口:`calibrate` 用已知有问题的改动(由事故回放用例在 main 上重新注入)与 +已知良好的已合并 PR,统计抓住问题的比例(TPR)与放过好改动的比例(TNR),结果写入 evals/review/results/; +`review_calibrate`(B59)对 docs/review/calibration/samples.json 里的真实历史样本(pr + head + 期望结论)逐个重跑 +独立评审,统计 TPR/TNR 与逐样本偏差,报告写本地 markdown,不评论任何 GitHub PR。 bin/dispatch review [--reviewer pi|codex|claude-code] + bin/dispatch review-calibrate [--reviewer 名称] [--output 路径] 评审校准打分(B59,不评论 PR) bin/harness review calibrate --reviewer pi [--limit N] [--jobs N] [--resume evals/review/results/<文件>] """ @@ -22,6 +27,7 @@ import datetime as dt import json import os +import re import subprocess import sys import tempfile @@ -32,12 +38,15 @@ from queue import Queue from typing import ClassVar -from engine.agents import dispatch, dispatch_host +from engine.agents import dispatch, dispatch_host, review_pack_io +from engine.agents import dispatch_observation as observation +from engine.core import alerts from engine.core.common import ENGINE_DIR, ROOT, changed_files, commit_field, git, load_rules from engine.routing import run_check PROMPT = ENGINE_DIR / "prompts" / "review_prompt.md" EVALS = ROOT / "evals" / "review" +CALIBRATION_SAMPLES = ROOT / "docs" / "review" / "calibration" / "samples.json" VERDICTS = ("通过", "不通过", "需用户验收") OTHER = {"claude-code": "codex", "codex": "claude-code"} REVIEWERS = ("opencode", "pi", "codex", "claude-code") @@ -51,6 +60,9 @@ class Verdict: summary: str = "" parsed: bool = True failure: str = "" # 评审方本身失败(报错退出、超时、额度用尽):不是结论,不计入校准,不评论为结论 + error_kind: str = "" # 失败的机器类别(timeout / reviewer_exit / no_output),供评审观察事件 + audit_model: str | None = None # 审计摘要用的模型(明确报告优先,其次显式请求;两者皆无为 None) + model_basis: str = "unknown" # reported / explicit_request / unknown(共用合同 C6) @property def flagged(self) -> bool: @@ -79,13 +91,14 @@ def parse_output(text: str) -> Verdict: class Reviewer: name = "" + model_name: str | None = None # 显式请求的模型(审计摘要用;展示名可带档位等修饰) env: ClassVar[dict[str, str]] = {} # 评审方专用的环境变量(如 OpenCode 的权限配置) def argv(self, prompt: str, workspace: Path, output: Path) -> list[str]: raise NotImplementedError - def read(self, stdout: str, output: Path) -> tuple[str, str]: - """返回 (最终文本, 模型)。""" + def read(self, stdout: str, output: Path) -> tuple[str, str, str]: + """返回 (最终文本, 展示用模型, model_basis:reported / explicit_request / unknown)。""" raise NotImplementedError @@ -94,6 +107,7 @@ class CodexReviewer(Reviewer): def __init__(self, model: str | None, effort: str | None = None): self.model, self.effort = model, effort + self.model_name = model def argv(self, prompt, workspace, output): # 没配置模型时沿用 Codex 自己的默认设置。 @@ -105,7 +119,8 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): base = self.model or "codex 默认模型" label = f"{base} {self.effort}" if self.effort else base - return (output.read_text(encoding="utf-8") if output.exists() else stdout), label + basis = "explicit_request" if self.model else "unknown" + return (output.read_text(encoding="utf-8") if output.exists() else stdout), label, basis class ClaudeReviewer(Reviewer): @@ -113,6 +128,7 @@ class ClaudeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["claude", "-p", "--allowedTools", "Read,Grep,Glob", "--output-format", "json"] @@ -124,9 +140,11 @@ def read(self, stdout, output): try: data = json.loads(stdout) except json.JSONDecodeError: - return stdout, self.model or "" - model = next(iter(data.get("modelUsage") or {}), self.model or "") - return str(data.get("result", "")), model + return stdout, self.model or "", "explicit_request" if self.model else "unknown" + reported = next(iter(data.get("modelUsage") or {}), "") + model = reported or self.model or "" + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return str(data.get("result", "")), model, basis class PiReviewer(Reviewer): @@ -134,6 +152,7 @@ class PiReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["pi", "-na", "--tools", "read,grep,find,ls", "-p", "--mode", "json", "--no-session"] @@ -143,7 +162,7 @@ def argv(self, prompt, workspace, output): def read(self, stdout, output): """取最后一条助手消息的文本与模型(pi 的 json 事件流)。""" - text, model = "", self.model or "" + text, model, reported = "", self.model or "", "" for line in stdout.splitlines(): try: event = json.loads(line) @@ -154,8 +173,11 @@ def read(self, stdout, output): parts = [part.get("text", "") for part in message.get("content", []) if part.get("type") == "text"] if any(parts): text = "\n".join(parts) + if message.get("model"): + reported = message["model"] model = message.get("model", model) - return text, model + basis = "reported" if reported else ("explicit_request" if self.model else "unknown") + return text, model, basis class OpenCodeReviewer(Reviewer): @@ -171,6 +193,7 @@ class OpenCodeReviewer(Reviewer): def __init__(self, model: str | None = None): self.model = model + self.model_name = model def argv(self, prompt, workspace, output): command = ["opencode", "run", "--pure", "--agent", "plan", "--format", "json", "--dir", str(workspace)] @@ -189,7 +212,7 @@ def read(self, stdout, output): part = event.get("part") or {} if event.get("type") == "text" and part.get("type") == "text": texts.append(part.get("text", "")) - return "\n".join(texts), self.model or "" + return "\n".join(texts), self.model or "", "explicit_request" if self.model else "unknown" def make_reviewer(name: str) -> Reviewer: @@ -218,13 +241,20 @@ def run_reviewer(reviewer: Reviewer, workspace: Path, timeout: float) -> tuple[V result = subprocess.run(reviewer.argv(prompt, workspace, output), cwd=workspace, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=timeout, check=False) - text, model = reviewer.read(result.stdout, output) + text, model, basis = reviewer.read(result.stdout, output) failure = "" if result.returncode == 0 else _failure_line(result.stdout + result.stderr, result.returncode) + error_kind = "" if result.returncode == 0 else "reviewer_exit" except subprocess.TimeoutExpired: - text, model, failure = "", "", f"超过 {int(timeout / 60)} 分钟未完成" + text, model, basis = "", "", "unknown" + failure, error_kind = f"超过 {int(timeout / 60)} 分钟未完成", "timeout" verdict = parse_output(text) if failure or not text.strip(): - verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出") + verdict = Verdict("评审失败", [], failure or "评审方没有输出", parsed=False, failure=failure or "没有输出", + error_kind=error_kind or "no_output") + # C6:模型明确报告优先;其次实际命令显式指定的请求模型;两者皆无不把配置默认值当已调用模型。 + verdict.audit_model = model if basis == "reported" else ( + reviewer.model_name if basis == "explicit_request" else None) + verdict.model_basis = basis return verdict, model, time.monotonic() - started @@ -281,14 +311,27 @@ def ci_summary(number: int, github) -> str: return "\n".join(f"- {item['name']}:{item['state']}({item['link']})" for item in checks) or "没有 CI 检查。" +def taskbook_from_pr_text(base: str, title: str, body: str, workspace: Path) -> tuple[str, str]: + """B68 回退:既有任务书探测(run_check.scope)未命中时,从 PR 标题与正文提取 `docs/plans/task-*.md` + 引用;引用文件在 base 上存在且 head 工作区仍可读时,返回 (相对路径, 全文)(与探测成功同一读取方式), + 否则返回 ("", "")(task.md 维持「无」)。只读 base 与工作区,不影响 designer_of 与评审只读性。""" + for rel in dict.fromkeys(re.findall(r"docs/plans/task-[\w.-]+\.md", f"{title}\n{body}")): + if not git("show", f"{base}:{rel}", cwd=workspace, check=False): + continue # base 上没有这份任务书 + try: + return rel, (workspace / rel).read_text(encoding="utf-8") + except OSError: + continue # head 上已被本 PR 删除:task_v1 无法按 路径@head 复取,视为未命中 + return "", "" + + def write_materials(workspace: Path, base: str, pr_text: str, task_text: str, ci_text: str = "") -> None: folder = workspace / "build" / "review" folder.mkdir(parents=True, exist_ok=True) (folder / "pr.md").write_text(pr_text, encoding="utf-8") (folder / "task.md").write_text(task_text or "无", encoding="utf-8") (folder / "ci.md").write_text(ci_text or "(校准样本没有 CI 运行)", encoding="utf-8") - diff = git("diff", "--no-color", f"{base}...HEAD", cwd=workspace) - (folder / "diff.patch").write_text(diff[:200_000], encoding="utf-8") + review_pack_io.write_diff(workspace, base) # T124:diff 不再 200K 截断,超 2MB 按文件边界分片 # 评审材料用被评审提交自己的引擎生成;迁移前的旧布局提交里引擎在 harness/ 平铺。 entry = workspace / ".harness" / "engine" / "cli.py" command = [str(entry), "review-pack"] if entry.exists() else [str(workspace / "harness" / "review_pack.py")] @@ -310,7 +353,7 @@ def run_check_records(workspace: Path, base: str) -> list[dict]: def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | None, head: str, seconds: float, - same_host: bool = False) -> str: + same_host: bool = False, audit: dict | None = None) -> str: lines = [ f"### 独立评审(试行):{verdict.verdict}", "", f"- 评审方:{reviewer}({model or '模型未报告'});设计方:{designer or '未能判定'};评审的提交:`{head[:12]}`;用时 {seconds / 60:.1f} 分钟", @@ -328,6 +371,9 @@ def render_comment(verdict: Verdict, reviewer: str, model: str, designer: str | data = {"verdict": verdict.verdict, "reviewer": reviewer, "model": model, "head": head, "findings": len(verdict.findings), "flagged": verdict.flagged, "parsed": verdict.parsed} lines += ["", f""] + if audit is not None: + # C6 审计摘要:与旧标记并列,单行 JSON,字段与材料编码见共用合同 C6;T305 只解析这个新标记。 + lines.append(f"") return "\n".join(lines) + "\n" @@ -335,9 +381,11 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= github = github or dispatch.GitHub(root) pr = json.loads(github._run(["gh", "pr", "view", str(number), "--json", "title,body,headRefName,headRefOid,baseRefName,state,mergeCommit"])) + trace = pr["headRefName"] # 评审事件的显式 trace:PR 的 headRefName + head = pr["headRefOid"] workspace = review_workspace(root) git("fetch", "--quiet", "origin", f"pull/{number}/head", "main", cwd=workspace) - checkout(workspace, pr["headRefOid"]) + checkout(workspace, head) base = review_base(pr, workspace) designer = designer_of(base, "HEAD", pr["headRefName"], workspace) name = reviewer_name or load_rules().get("review", {}).get("reviewer") or OTHER.get(designer or "") @@ -349,19 +397,46 @@ def review_pr(number: int, reviewer_name: str | None, root: Path = ROOT, github= return 2 target = run_check.scope(base, "HEAD", pr["headRefName"], workspace) task_text = (workspace / target.taskbook).read_text(encoding="utf-8") if target and target.taskbook else "" + taskbook_rel = target.taskbook if target and target.taskbook else "" + if not taskbook_rel: # 探测未命中才回退,命中时行为逐字不变 + taskbook_rel, task_text = taskbook_from_pr_text(base, pr["title"], pr["body"] or "", workspace) write_materials(workspace, base, f"# {pr['title']}\n\n{pr['body']}", task_text, ci_summary(number, github)) + materials = observation.review_materials(workspace, base, head, taskbook_rel or None, number) + executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} + independent = None if designer is None else name != designer verdict, model, seconds = run_reviewer(make_reviewer(name), workspace, load_rules().get("review", {}).get( "timeout_minutes", 30) * 60) if verdict.failure: + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=name in executors, + failure=verdict.failure, error_kind=verdict.error_kind) + alerts.publish(trace, "review_error", pr=number, gh=github) # 评审方自身失败也复用发布(D067) print(f"PR #{number}:评审方失败({verdict.failure}),没有评论,保留待评审标签") return 1 - executors = {record.get("gen_ai.agent.name") for record in run_check_records(workspace, base)} - github.comment(number, render_comment(verdict, name, model, designer, pr["headRefOid"], seconds, - same_host=name in executors)) + same_host = name in executors + audit = observation.review_audit(trace_id=trace, head=head, base=base, reviewer=name, + model=verdict.audit_model, model_basis=verdict.model_basis, + designer=designer, + implementers=sorted(item for item in executors if item), + independent=independent, same_host=same_host, parsed=verdict.parsed, + verdict=verdict.verdict, duration_ms=int(seconds * 1000), + findings=verdict.findings, materials=materials) + body = render_comment(verdict, name, model, designer, head, seconds, same_host, audit) + url = github.comment(number, body) + # 事件在评论实际发布后写:URL 与评论字节哈希只有此刻可知(C6:摘要不预写 URL、不含自身哈希)。 + observation.review(trace=trace, head=head, reviewer=name, verdict=verdict.verdict, + duration_ms=int(seconds * 1000), findings=verdict.findings, materials=materials, + designer=designer, model=verdict.audit_model, model_basis=verdict.model_basis, + parsed=verdict.parsed, independent=independent, same_host=same_host, + comment=url, comment_body=body) try: github._run(["gh", "pr", "edit", str(number), "--remove-label", LABEL], agent=True) except RuntimeError: pass # 没有该标签 + if verdict.verdict == "不通过": # D067:评审否决在已有评论发布后补标签/告警,不重跑评审 + alerts.publish(trace, "review_rejected", pr=number, gh=github) print(f"PR #{number}:{verdict.verdict}({name}),已评论") return 0 @@ -552,6 +627,157 @@ def score(results: list[dict]) -> dict: } +# ---- 评审校准(B59:真实历史样本) ---- + +def load_samples(samples_path: Path) -> list[dict]: + """装载校准样本清单:每条必须有 pr(正整数)、head(40 位提交号)、expected(合法结论)与一句 reason。""" + try: + entries = json.loads(samples_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"校准样本清单读不到或不是合法 JSON:{samples_path}({error})") from error + if not isinstance(entries, list) or not entries: + raise ValueError(f"校准样本清单须是非空的样本数组:{samples_path}") + for index, entry in enumerate(entries, 1): + where = f"校准样本第 {index} 条" + if not isinstance(entry, dict): + raise TypeError(f"{where} 不是对象") + pr, head = entry.get("pr"), entry.get("head") + expected, reason = entry.get("expected"), entry.get("reason") + if isinstance(pr, bool) or not isinstance(pr, int) or pr <= 0: + raise ValueError(f"{where} 缺少合法的 pr 号(正整数),得到 {pr!r}") + if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{40}", head): + raise ValueError(f"{where}(PR #{pr})的 head 必须是 40 位十六进制提交号,得到 {head!r}") + if expected not in VERDICTS: + raise ValueError(f"{where}(PR #{pr})的 expected 只能是 {'、'.join(VERDICTS)},得到 {expected!r}") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"{where}(PR #{pr})缺少一句裁决依据(reason)") + return entries + + +def prepare_head_sample(workspace: Path, sample: dict) -> tuple[str, str]: + """把评审工作区检出到样本 head,返回 (base, PR 文本),材料组装与 review_pr 同一套(write_materials)。 + + 样本 head 不在 main 上时(中间轮次的 head)从 pull//head 取,对象不可得即报错(该样本进 errors)。 + base 与 review_base 同口径:已合并的样本用合并提交的第一父提交,未合并的退回与 origin/main 的合并基。 + """ + pr, head = sample["pr"], sample["head"] + git("fetch", "--quiet", "origin", f"pull/{pr}/head", cwd=workspace, check=False) + if not git("rev-parse", "--verify", "--quiet", f"{head}^{{commit}}", cwd=workspace, check=False): + raise ValueError(f"样本 head {head[:12]} 检出失败:origin/main 与 pull/{pr}/head 里都没有这个提交") + checkout(workspace, head) + merge = git("log", "--merges", "--first-parent", "--format=%H %s", "origin/main", cwd=workspace) + sha = next((line.split()[0] for line in merge.splitlines() if f"#{pr} " in f"{line} "), None) + if sha: + base = git("rev-parse", f"{sha}^1", cwd=workspace) + body = git("log", "-1", "--format=%B", sha, cwd=workspace) + else: + base = git("merge-base", "origin/main", "HEAD", cwd=workspace) + body = git("log", "-1", "--format=%B", head, cwd=workspace) + return base, f"# PR #{pr}\n\n{body}\n" + + +def sample_deviates(item: dict) -> bool: + """与期望不一致:期望不通过却放行、期望通过却抓中;期望需用户验收时,给了无阻断/严重发现的明确通过也算偏差。""" + if item["expected"] == "不通过": + return not item["flagged"] + if item["expected"] == "通过": + return item["flagged"] + return item["verdict"] != "需用户验收" and not item["flagged"] + + +def score_samples(results: list[dict]) -> dict: + """TPR=期望不通过的样本里抓中的比例,TNR=期望通过的样本里放行的比例。样本检出失败(error)与评审方 + 失败(failure)都进 errors、不进分母;有输出但没有可用结论(unparsed)同样不进分母,单独计数。""" + scored = [item for item in results if "error" not in item and not item.get("failure") and item.get("parsed", True)] + bad = [item for item in scored if item["expected"] == "不通过"] + good = [item for item in scored if item["expected"] == "通过"] + return { + "total": len(results), "bad": len(bad), "good": len(good), + "caught": sum(item["flagged"] for item in bad), + "released": sum(not item["flagged"] for item in good), + "deviations": sum(sample_deviates(item) for item in scored), + "errors": sum(1 for item in results if "error" in item or item.get("failure")), + "unparsed": sum("error" not in item and not item.get("failure") and not item.get("parsed", True) + for item in results), + "tpr": round(sum(item["flagged"] for item in bad) / len(bad), 3) if bad else None, + "tnr": round(sum(not item["flagged"] for item in good) / len(good), 3) if good else None, + } + + +def _cell(text: str) -> str: + """表格单元格转义:竖线与换行会破坏 markdown 表格。""" + return text.replace("|", "\\|").replace("\n", " ") + + +def render_calibration_report(samples_path: Path, results: list[dict], summary: dict, reviewer: str, + model: str) -> str: + def ratio(part: int, whole: int) -> str: + return f"{part / whole:.3f}({part}/{whole})" if whole else "n/a(分母 0)" + + lines = [ + "# 评审校准报告", "", + f"- 样本清单:{samples_path}", + f"- 评审方:{reviewer}{'(' + model + ')' if model else ''};日期:{dt.date.today().isoformat()}", + f"- TPR(期望不通过的抓中率):{ratio(summary['caught'], summary['bad'])}", + f"- TNR(期望通过的放行率):{ratio(summary['released'], summary['good'])}", + f"- 偏差 {summary['deviations']} 条;errors {summary['errors']} 条、unparsed {summary['unparsed']} 条不进分母", + "", "## 逐样本结果", "", + "| PR | head | 期望 | 实际结论 | 偏差 | 裁决依据 |", "|---|---|---|---|---|---|", + ] + for item in results: + if "error" in item: + actual, deviation = f"样本错误:{_cell(item['error'])}", "未计分" + elif item.get("failure"): + actual, deviation = f"评审方失败:{_cell(item['failure'])}", "未计分" + else: + actual = _cell(item["verdict"]) + ("(抓住)" if item["flagged"] else "") + deviation = "是" if sample_deviates(item) else "—" + lines.append(f"| #{item['pr']} | {item['head'][:12]} | {item['expected']} | {actual} | {deviation} | " + f"{_cell(item['reason'])} |") + lines += ["", "## 结论", "", + ("- 校准是测量工具:本报告只给出 TPR/TNR 与逐样本偏差,不改原判定、不评论任何 PR;" + "换评审方或模型后应在同一清单上重跑再比较。")] + return "\n".join(lines) + "\n" + + +def review_calibrate(review_name: str | None, samples_path: Path, output: Path, *, root: Path = ROOT, + reviewer: Reviewer | None = None) -> int: + """B59 评审校准:对真实历史样本(pr + head + 期望结论)逐个重跑独立评审,统计 TPR/TNR 与逐样本偏差, + 报告写本地 markdown(不评论到任何 GitHub PR)。样本检出失败与评审方失败进 errors,不进 TPR/TNR 分母。""" + samples = load_samples(samples_path) + name = review_name or load_rules().get("review", {}).get("reviewer") + runner = reviewer or (make_reviewer(name) if name else None) + if runner is None: + print("未能判定评审方:用 --reviewer 指定,或在 rules.toml [review] 配置 reviewer") + return 2 + workspace = review_workspace(root) + git("fetch", "--quiet", "origin", "main", cwd=workspace) + results = [] + for sample in samples: + item = {"pr": sample["pr"], "head": sample["head"], "expected": sample["expected"], + "reason": sample["reason"]} + try: + base, pr_text = prepare_head_sample(workspace, sample) + write_materials(workspace, base, pr_text, "") + except (ValueError, RuntimeError, subprocess.CalledProcessError) as error: + results.append({**item, "error": str(error)}) + print(f"PR #{sample['pr']} @ {sample['head'][:12]}:样本错误({error})", flush=True) + continue + verdict, model, seconds = run_reviewer(runner, workspace, load_rules().get("review", {}).get( + "timeout_minutes", 30) * 60) + results.append({**item, "verdict": verdict.verdict, "flagged": verdict.flagged, "parsed": verdict.parsed, + "failure": verdict.failure, "model": model, "seconds": round(seconds)}) + print(f"PR #{sample['pr']} @ {sample['head'][:12]}:{verdict.verdict}{'(抓住)' if verdict.flagged else ''}" + f"{':' + verdict.failure if verdict.failure else ''}", flush=True) + summary = score_samples(results) + model = next((item["model"] for item in results if item.get("model")), "") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(render_calibration_report(samples_path, results, summary, runner.name, model), encoding="utf-8") + print(f"校准完成:TPR {summary['tpr']}、TNR {summary['tnr']}、偏差 {summary['deviations']} 条、" + f"errors {summary['errors']} 条;报告已写入 {output}") + return 0 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) diff --git a/.harness/engine/agents/review_pack.py b/.harness/engine/agents/review_pack.py index 149d02f..0d1f2fa 100644 --- a/.harness/engine/agents/review_pack.py +++ b/.harness/engine/agents/review_pack.py @@ -3,7 +3,8 @@ bin/harness review-pack --base origin/main [--head HEAD] [--output build/review/pack.md] [--no-run] 内容:改动概要、风险等级(risk.py)、修复证据(evidence.py)、本地 verify 汇总(仅当与当前 head 一致)、 -本次改动涉及的验收编号及其中的人工项、评审清单入口(docs/templates/review-checklist.md)。 +本次改动涉及的验收编号及其中的人工项、评审清单入口(docs/templates/review-checklist.md)、评审 diff +材料清单(T124:build/review/ 下的 diff 文件与行数,分片时在顶部声明无截断)。 """ from __future__ import annotations @@ -55,6 +56,24 @@ def verify_section(head_sha: str, summary_path: Path = VERIFY_SUMMARY) -> list[s return lines +def diff_materials(cwd: Path = ROOT) -> list[str]: + """评审 diff 材料清单(T124,作为 write_materials 落盘 diff 的消费方):列出 build/review/ 下 + 全部 diff 文件与各自行数;分片时在清单顶部加一行「diff 分 N 片,无截断」。目录里没有 diff + 文件(独立运行、旧引擎组装)时不加这一节。""" + folder = cwd / "build" / "review" + files = sorted(folder.glob("diff*.patch")) if folder.is_dir() else [] + if not files: + return [] + lines = [] + for path in files: + data = path.read_bytes() + count = data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0) + lines.append(f"- `{path.name}`:{count} 行") + if len(files) > 1: + lines.insert(0, f"diff 分 {len(files)} 片,无截断。") + return [*lines, ""] + + def build(base: str, head: str = "HEAD", run_tests: bool = True, cwd: Path = ROOT) -> str: head_sha = git("rev-parse", head, cwd=cwd) commits = git("rev-list", "--count", f"{base}..{head}", cwd=cwd) @@ -62,6 +81,7 @@ def build(base: str, head: str = "HEAD", run_tests: bool = True, cwd: Path = ROO parts = [ f"# 评审证据包 `{git('rev-parse', '--short', base, cwd=cwd)}` → `{head_sha[:7]}`", "", + *diff_materials(cwd), f"{commits} 个提交;{files or '无改动'}。评审清单见 `docs/templates/review-checklist.md`。", "", risk.render_markdown(risk.classify(base, head, cwd)), diff --git a/.harness/engine/agents/review_pack_io.py b/.harness/engine/agents/review_pack_io.py new file mode 100644 index 0000000..eadd036 --- /dev/null +++ b/.harness/engine/agents/review_pack_io.py @@ -0,0 +1,66 @@ +"""评审 diff 材料的落盘(T124,待办 B74):去掉 200K 字符静默截断,大 diff 按文件边界分片。 + +总量 ≤ SHARD_LIMIT 时维持旧形态:单个 `build/review/diff.patch`(`git diff --no-color` 加 +git() 的去结尾换行口径不变,只是不再截断)。超过时按文件边界分片为 `diff-01.patch`、 +`diff-02.patch`…:各片按序拼接与完整 diff 逐字节等价,分片只落在文件边界(不拆半个文件)。 +单个文件的 diff 本身超过 SHARD_LIMIT 时独占一片(此时该片必然超上限:文件边界优先于片大小)。 +分片清单由 review_pack 作为消费方在 pack.md 里列出(文件名与行数、分 N 片声明)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from engine.core.common import git + +SHARD_LIMIT = 2 * 1024 * 1024 # 每片上限 2MB;总量以内维持单个 diff.patch +_FILE_HEADER = re.compile(rb"(?m)^diff --git ") + + +def write_diff(workspace: Path, base: str) -> list[Path]: + """把 `base...HEAD` 的评审 diff 写入 workspace 的 build/review/,返回写入的文件(按片序)。 + + 替代 write_materials 原来按 200_000 字符静默截断的 diff 段;写入前清掉目录里不再属于本次 + 结果的旧 diff 文件,保证 pack 的材料清单与磁盘一致(评审工作区跨 PR 复用时尤其如此)。 + """ + diff = git("diff", "--no-color", f"{base}...HEAD", cwd=workspace).encode("utf-8") + folder = workspace / "build" / "review" + folder.mkdir(parents=True, exist_ok=True) + shards = _shards(_file_chunks(diff)) + files = [folder / ("diff.patch" if len(shards) == 1 else f"diff-{index:02d}.patch") + for index in range(1, len(shards) + 1)] + for stale in folder.glob("diff*.patch"): + if stale not in files: + stale.unlink() + for path, shard in zip(files, shards): + path.write_bytes(shard) + return files + + +def _file_chunks(diff: bytes) -> list[bytes]: + """按文件边界切块:每个行首 `diff --git ` 开启一个文件段(diff 的正文行都有 +/空格/- 前缀, + 不会误配);没有 diff 或只有单个文件段时原样返回一段(空 diff 也写一个空的 diff.patch,与 + 旧行为一致)。""" + starts = [match.start() for match in _FILE_HEADER.finditer(diff)] + if len(starts) <= 1: + return [diff] + if starts[0] > 0: # 理论上不出现的前导内容并入第一段 + starts.insert(0, 0) + return [diff[start:end] for start, end in zip(starts, [*starts[1:], len(diff)])] + + +def _shards(chunks: list[bytes]) -> list[bytes]: + """贪心装片:整段装入,再放下一段会超上限时先封片;单个文件段本身超 SHARD_LIMIT 时独占一片。""" + shards: list[bytes] = [] + current: list[bytes] = [] + size = 0 + for chunk in chunks: + if current and size + len(chunk) > SHARD_LIMIT: + shards.append(b"".join(current)) + current, size = [], 0 + current.append(chunk) + size += len(chunk) + if current: + shards.append(b"".join(current)) + return shards diff --git a/.harness/engine/agents/run_timeline.py b/.harness/engine/agents/run_timeline.py new file mode 100644 index 0000000..f9df202 --- /dev/null +++ b/.harness/engine/agents/run_timeline.py @@ -0,0 +1,164 @@ +"""运行记录时间线与固定链头(B46 T201;B77 T125 收敛为本次 attempt 窗口的安全索引)。 + +职责边界(任务书 F1、共用合同 C0):事件由 dispatch_observation(T105)写入,本模块只读——从本机库 +取该 trace 已发生的事件,组装 stages/anchors 摘要。dispatch.write_record 在记录落盘前调用 record_fields +取链头与摘要,写入摘要并提交后用 fix_anchor 固定 run_record 锚点;锚点指向已发生事件的前缀,不含本次 +尚未发生的 push_pr/ci_wait,也不为补时间线追加推送。本模块不判定退出/预算/推送,读取或组装失败一律 +退化为空摘要(锚点为空),不改变派发的判定、返回码与 gh 调用序列。 + +stages 是记录的索引而非事件流(B77 收敛):只含本次 attempt 窗口的事件(按链内 ci_wait/escalate 边界 +推导 attempt,最后一次边界之后为本窗口),每项只保留 stage/step/status/ts/duration_ms/attempt/round +七个标量;inputs/outputs/decision/actor/source/head_hash 等细节留在事件库。更早 attempt 已发生的 +push_pr/ci_wait 各保留一条指针行(stage/step/status="prior"/attempt),标记链上有过衔接而不重复携带 +历史(resume 多轮曾膨胀至 1.1MB 被卫生守卫拦下)。anchors 不变;保底:组装后 stages 超 256KB 从尾部 +截断并标注 stages_truncated/stages_total(记录 JSON 的 512KB 自检在 dispatch.write_record)。 +""" + +from __future__ import annotations + +import contextlib +import json +import re +import sys + +from engine.core import events, events_db +from engine.core.common import load_rules + +_SOURCE = "local" # 记录时间线只取本机来源链;CI/GitHub 事实由各自来源记录 +_ANCHOR_STAGE = "dispatch" # 锚点在派发写记录时固定 +_FIXED_IN = "run_record" +_EVENT_COLUMNS = ("hash", "ts", "stage", "step", "status", "duration_ms") +# 单条摘要瘦身(B77):只保留七个标量字段,记录是索引,细节留在事件库。 +_POINTER_STATUS = "prior" # 更早 attempt 的 push_pr/ci_wait 指针行状态 +_STAGES_BUDGET = 256 * 1024 # 组装后 stages 的保底预算,超出从尾部截断并标注 +_ROUND_STEPS = frozenset(("executor_round", "local_verify")) +# 尝试边界:CI 等待与升级都意味着上一次尝试已结束,其后的事件属于下一次尝试。 +_ATTEMPT_BOUNDARIES = frozenset(("ci_wait", "escalate")) +# 更早 attempt 只保留指针行的衔接步骤:其余事件不重复入记录。 +_POINTER_STEPS = frozenset(("push_pr", "ci_wait")) +# 落盘前自检(B77,dispatch.write_record 调用):记录 JSON 上限与 CI/runner 工作区路径占位。 +RECORD_MAX_BYTES = 512 * 1024 +# 卫生规则缺失时的内置本机路径模式(与 templates 的 home_path_pattern 同款)。 +_HOME_PATH_PATTERN = (r"(/Users/|/home/|C:\\Users\\)" + r"(?!(x|you|me|user|name|example|test|someone)[/\\])[A-Za-z0-9._-]+[/\\]") + + +def record_fields(trace_id: str) -> tuple[dict, str | None]: + """记录的新增摘要字段(trace_id/stages/anchors,截断时另有标注)与记录落盘前的本机链头。 + + 链头必须在记录落盘前读取:锚点指向已发生事件的前缀,不含尚未发生的 push_pr/ci_wait。 + 读取失败或尚无事件时 stages/anchors 为空列表、链头为 None(锚点为空),原派发行为不变。 + """ + stages, head = _read_stages(trace_id) + stages, total = fit_stages(stages, _STAGES_BUDGET) + anchors = [{"source": _SOURCE, "stage": _ANCHOR_STAGE, "head_hash": head, "fixed_in": _FIXED_IN}] if head else [] + fields: dict = {"trace_id": trace_id, "stages": stages, "anchors": anchors} + if total is not None: + fields["stages_truncated"] = True + fields["stages_total"] = total + return fields, head + + +def fit_stages(stages: list[dict], budget: int) -> tuple[list[dict], int | None]: + """组装后的保底(B77):stages 序列化超过 budget 字节时从尾部逐条截断。 + + 返回 (保留的条目, 截断前总条数);未截断时总数为 None。stages_truncated/stages_total 标注由调用方 + 据此写入记录。 + """ + total = len(stages) + kept = stages + while kept and len(json.dumps(kept, ensure_ascii=False).encode("utf-8")) > budget: + kept = kept[:-1] + return kept, (total if len(kept) != total else None) + + +def fix_anchor(trace_id: str, head_hash: str | None) -> None: + """记录落盘并提交后,把落盘前链头固定为 run_record 锚点;没有链头(无事件或库不可用)不登记。""" + if head_hash: + events.set_anchor(trace_id, _ANCHOR_STAGE, head_hash, _FIXED_IN, _SOURCE) + + +def fit_record(record: dict) -> None: + """记录落盘前自检(B77):JSON 超 512KB 时从 stages 尾部逐条截断并标注,再提交。 + + stages_total 保持「截断前组装总数」口径(组装侧 256KB 保底已截断时该键已在记录里,不重复累计)。 + 让卫生守卫在记录提交步杀派发的两例(stages 超 1MB)都由组装侧收敛拦在前;这里是最后保底。 + """ + if len(json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8")) <= RECORD_MAX_BYTES: + return + dropped = 0 + while record.get("stages") and len( + json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8")) > RECORD_MAX_BYTES: + record["stages"].pop() + dropped += 1 + if dropped: + record["stages_truncated"] = True + if "stages_total" not in record: + record["stages_total"] = len(record["stages"]) + dropped + detail = f"stages 已截断(保留 {len(record.get('stages') or [])} 条)" if dropped else "无 stages 可截断,按原样提交" + print(f"运行记录自检:记录 JSON 超过 {RECORD_MAX_BYTES // 1024} KB,{detail}。", file=sys.stderr) + + +def placeholder_workspace(text: str) -> str: + """prompt 快照自检(B77):CI/runner 工作区本机路径按卫生规则占位为 ,守卫不再拦。""" + cleaned, hits = _home_path_re().subn("", text) + if hits: + print(f"运行记录自检:prompt 快照含 {hits} 处 CI/runner 工作区路径,已占位为 。", + file=sys.stderr) + return cleaned + + +def _home_path_re() -> re.Pattern[str]: + """卫生规则的本机路径模式(与守卫同源);规则缺失或不可读时退回引擎内置同款模式。""" + try: + pattern = load_rules()["hygiene"]["home_path_pattern"] + except Exception: # noqa: BLE001 规则不可得时按内置模式自检,不影响派发 + pattern = _HOME_PATH_PATTERN + return re.compile(pattern) + + +def _read_stages(trace_id: str) -> tuple[list[dict], str | None]: + """读取本机链并组装本次 attempt 窗口的安全摘要;任何失败返回空摘要与 None 链头(观察旁路)。 + + attempt 按链内边界推导(ci_wait/escalate 之后进入下一次尝试,边界事件本身属于它结束的尝试); + 记录只含本次 attempt 的事件,更早 attempt 已发生的 push_pr/ci_wait 按链序各留一条指针行。 + 链头仍取整条链的最后一个事件(anchors 语义不变)。 + """ + try: + rows = _read_events(trace_id) + except Exception: # noqa: BLE001 观察旁路:摘要组装失败不得影响派发 + return [], None + if not rows: + return [], None + attempt, round_no = 1, 0 + numbered: list[tuple[dict, int, int]] = [] + for row in rows: + if row["step"] == "executor_round": + round_no += 1 + numbered.append((row, attempt, round_no if row["step"] in _ROUND_STEPS else 0)) + if row["step"] in _ATTEMPT_BOUNDARIES: + attempt, round_no = attempt + 1, 0 + current = numbered[-1][1] + items: list[dict] = [] + for row, no, rnd in numbered: + if no == current: + items.append({"stage": row["stage"], "step": row["step"], "status": row["status"], + "ts": row["ts"], "duration_ms": row["duration_ms"], "attempt": no, "round": rnd}) + elif row["step"] in _POINTER_STEPS: + items.append({"stage": row["stage"], "step": row["step"], "status": _POINTER_STATUS, + "ts": row["ts"], "duration_ms": row["duration_ms"], + "attempt": no, "round": rnd}) + return items, rows[-1]["hash"] + + +def _read_events(trace_id: str) -> list[dict]: + """按 seq 读出本机链上的事件;库缺失返回空。连接沿用 events_db 的连接参数(busy_timeout=5000): + 库锁竞争或恢复窗口内等待而非立即失败(B69)。摘要只需要索引列,引用与 outputs 留在库里按需查。 + """ + path = events_db.db_path() + if path is None or not path.exists(): + return [] + with contextlib.closing(events_db._connect(path)) as conn: + return [dict(zip(_EVENT_COLUMNS, values)) for values in conn.execute( + f"SELECT {','.join(_EVENT_COLUMNS)} FROM events WHERE source=? AND trace_id=? ORDER BY seq", + (_SOURCE, trace_id))] diff --git a/.harness/engine/checks/base_tests.py b/.harness/engine/checks/base_tests.py index 7d400d7..10688fa 100644 --- a/.harness/engine/checks/base_tests.py +++ b/.harness/engine/checks/base_tests.py @@ -23,6 +23,7 @@ import tempfile from pathlib import Path +from engine.core import events from engine.core.common import ROOT, clean_git_env, git from engine.routing import risk @@ -73,6 +74,16 @@ def run(base: str, head: str = "HEAD", cwd: Path = ROOT) -> tuple[bool, bool, st return completed.returncode == 0, not intended or completed.returncode == TAMPERED, summary +def _record(base: str, head: str, ok: bool, enforced: bool, summary: str) -> None: + """观察旁路:一条 base_tests 事件(是否通过、是否强制、安全摘要行),失败不影响原判定。""" + try: + events.emit(stage="ci", step="base_tests", status="ok" if ok else "fail", + inputs=[events.ref("rev", base), events.ref("rev", head)], + outputs={"ok": ok, "enforced": enforced, "summary": summary or None}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -80,6 +91,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--repo", type=Path, default=ROOT, help="要检查的仓库(默认本仓库)") args = parser.parse_args(argv) ok, enforced, summary = run(args.base, args.head, cwd=args.repo) + _record(args.base, args.head, ok, enforced, summary) # 观察:结果、强制与摘要行 if ok: print(f"✓ base 版本的已有测试在 head 代码上全部通过({summary})") return 0 diff --git a/.harness/engine/checks/evidence.py b/.harness/engine/checks/evidence.py index 064696d..2bbf164 100644 --- a/.harness/engine/checks/evidence.py +++ b/.harness/engine/checks/evidence.py @@ -33,6 +33,7 @@ from pathlib import Path from engine.checks import verify +from engine.core import events from engine.core.cases import load_project_cases from engine.core.common import ROOT, clean_git_env, commit_field, git, path_matches @@ -386,6 +387,21 @@ def render_markdown(evidences: dict[str, DefectEvidence], base: str, head: str, return "\n".join(lines) + "\n" +def _record(evidences: dict[str, DefectEvidence]) -> None: + """观察旁路:每个缺陷编号一条事件(有无提交、是否改代码、修复前后结果),不含缺陷正文。""" + try: + for evidence in evidences.values(): + events.emit(stage="ci", step="evidence", status="ok" if evidence.ok else "fail", + outputs={"defect": evidence.defect, "commits": len(evidence.commits), + "doc_only": evidence.doc_only, "code_files": len(evidence.code_files), + "tests": len(evidence.python_tests) + len(evidence.swift_refs), + "before": evidence.before or None, "after": evidence.after or None, + "withdrawn": bool(evidence.withdrawn_by), + "problems": len(evidence.problems), "warnings": len(evidence.warnings)}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -398,10 +414,11 @@ def main(argv: list[str] | None = None) -> int: ids = [item for item in (args.ids or "").split(",") if item] evidences = analyse( - args.base, args.head, ids or None, run_tests=not args.no_run, swift=args.swift + args.base, args.head, ids or None, run_tests=not args.no_run, swift=args.swift, cwd=ROOT ) + _record(evidences) # 观察:每个缺陷的提交、代码改动与修复前后结果 coverage = replay_coverage(evidences) - report = render_markdown(evidences, args.base, args.head) + "\n" + render_replay(coverage) + report = render_markdown(evidences, args.base, args.head, ROOT) + "\n" + render_replay(coverage) print(report) if args.markdown: with open(args.markdown, "a") as handle: diff --git a/.harness/engine/checks/hygiene.py b/.harness/engine/checks/hygiene.py index 56c287f..cbc225d 100644 --- a/.harness/engine/checks/hygiene.py +++ b/.harness/engine/checks/hygiene.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from pathlib import Path +from engine.core import events from engine.core.common import ROOT, git, load_rules, parse_added_lines, path_matches @@ -141,6 +142,18 @@ def scan_range(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | No return check_paths(changed, rules) + check_sizes(sizes, rules) + check_added_lines(parse_added_lines(diff), rules) +def _record(scope: str, violations: list[Violation]) -> None: + """观察旁路:每条违规一条事件(路径与规则名,不含命中内容),汇总一条(范围与条数)。""" + try: + for violation in violations: + events.emit(stage="ci", step="hygiene.violation", status="fail", + outputs={"path": violation.path, "rule": violation.kind}) + events.emit(stage="ci", step="hygiene.summary", status="fail" if violations else "ok", + outputs={"scope": scope, "violations": len(violations)}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) scope = parser.add_mutually_exclusive_group() @@ -149,13 +162,15 @@ def main(argv: list[str] | None = None) -> int: scope.add_argument("--range", metavar="BASE", help="BASE...HEAD 的改动") args = parser.parse_args(argv) + scope_name = "staged" if args.staged else f"range:{args.range}" if args.range else "tracked" if args.staged: - violations = scan_staged() + violations = scan_staged(cwd=ROOT) elif args.range: - violations = scan_range(args.range) + violations = scan_range(args.range, cwd=ROOT) else: - violations = scan_tracked() + violations = scan_tracked(cwd=ROOT) + _record(scope_name, violations) # 观察:违规路径与规则名、范围与条数(不含命中内容) for violation in violations: print(violation.render()) if violations: diff --git a/.harness/engine/checks/integrity.py b/.harness/engine/checks/integrity.py index 48a9dad..9a0bb0e 100644 --- a/.harness/engine/checks/integrity.py +++ b/.harness/engine/checks/integrity.py @@ -6,6 +6,8 @@ bin/harness integrity 核对(verify 各档运行) bin/harness integrity --print 打印当前目录树哈希 + +核对本身是一条观察事件(设计 3.3):lock 引用、目录摘要、文件计数与失败签名,写入失败不影响原判定。 """ from __future__ import annotations @@ -15,22 +17,34 @@ import json from pathlib import Path -from engine.core.common import ENGINE_DIR, HARNESS_DIR +from engine.core import events +from engine.core.common import ENGINE_DIR, HARNESS_DIR, git LOCK_FILE = HARNESS_DIR / "engine.lock" IGNORED_PARTS = {"__pycache__"} IGNORED_SUFFIXES = {".pyc", ".pyo"} -def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: - """按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256;忽略字节码缓存。""" +def _scan(engine_dir: Path) -> tuple[str, dict[str, str]]: + """一次遍历同时得到目录树哈希与逐文件哈希(同一过滤:忽略字节码缓存)。 + + 哈希算法与既有实现逐字节一致:按相对路径排序,对「路径 NUL 内容哈希」逐行求 sha256。 + """ digest = hashlib.sha256() + hashes: dict[str, str] = {} for path in sorted(p for p in engine_dir.rglob("*") if p.is_file()): rel = path.relative_to(engine_dir) if IGNORED_PARTS & set(rel.parts) or path.suffix in IGNORED_SUFFIXES: continue - digest.update(rel.as_posix().encode() + b"\0" + hashlib.sha256(path.read_bytes()).hexdigest().encode() + b"\n") - return f"sha256:{digest.hexdigest()}" + file_hash = hashlib.sha256(path.read_bytes()).hexdigest() + digest.update(rel.as_posix().encode() + b"\0" + file_hash.encode() + b"\n") + hashes[rel.as_posix()] = file_hash + return f"sha256:{digest.hexdigest()}", hashes + + +def tree_hash(engine_dir: Path = ENGINE_DIR) -> str: + """目录树哈希(install 写锁与 integrity 核对共用同一算法)。""" + return _scan(engine_dir)[0] def read_lock(path: Path = LOCK_FILE) -> dict: @@ -42,7 +56,10 @@ def write_lock(path: Path, version: str, commit: str, tree: str) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") -def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[bool, str]: +def check(engine_dir: Path | None = None, lock_file: Path | None = None) -> tuple[bool, str]: + """核对引擎目录与锁文件;参数缺省取当前安装位置(延迟绑定,便于隔离测试与工具复用)。""" + engine_dir = ENGINE_DIR if engine_dir is None else engine_dir + lock_file = LOCK_FILE if lock_file is None else lock_file if engine_dir.parent != lock_file.parent: return True, "引擎不在业务仓库的 .harness/ 下(引擎仓库自身),无需核对" if not lock_file.exists(): @@ -58,6 +75,63 @@ def check(engine_dir: Path = ENGINE_DIR, lock_file: Path = LOCK_FILE) -> tuple[b return True, f"引擎 {lock.get('version')}({str(lock.get('commit', ''))[:12]})与锁文件一致 ✓" +def _changed_count(engine_dir: Path) -> int | None: + """引擎目录里未提交的变更文件数(含未跟踪)。 + + 锁文件只有整体摘要,无法逐文件回溯;未提交改动是「改了引擎而没走 upgrade」的主要形态, + 以 git HEAD 为基线给出被改文件数(不含内容)。git 不可用时返回 None。 + """ + try: + out = git("status", "--porcelain", "--untracked-files=all", "--", ".", + cwd=engine_dir, check=True, isolate=True) + except RuntimeError: + return None + return sum(1 for line in out.splitlines() if line.strip()) + + +def _metadata() -> tuple[list[dict], dict, str | None]: + """事件的 lock 引用、目录摘要、文件计数与失败签名;各项准备失败只让对应字段缺失。""" + inputs: list[dict] = [] + try: + inputs.append(events.file_ref("lock", LOCK_FILE)) + except OSError: + pass + signature = None if LOCK_FILE.exists() else "lock_missing" + outputs: dict = {} + try: + tree, hashes = _scan(ENGINE_DIR) + except OSError: + return inputs, outputs, signature + outputs["tree"] = tree + outputs["files"] = len(hashes) + changed = _changed_count(ENGINE_DIR) + if changed is not None: + outputs["changed"] = changed + try: + lock = read_lock(LOCK_FILE) + except Exception: # noqa: BLE001 缺锁或坏锁:事件只缺对应字段,不改变原判定 + lock = {} + if lock.get("tree"): + outputs["lock.tree"] = lock["tree"] + if signature is None and lock["tree"] != tree: + signature = "tree_mismatch" + return inputs, outputs, signature + + +def _record(ok: bool) -> None: + """观察旁路:一条 integrity 内容事件(stage=verify);准备失败不得影响原判定与输出。""" + try: + if ENGINE_DIR.parent != LOCK_FILE.parent: # 引擎仓库自身:核对不适用 + events.emit(stage="verify", step="integrity", status="skip") + return + inputs, outputs, signature = _metadata() + events.emit(stage="verify", step="integrity", status="ok" if ok else "fail", + inputs=inputs or None, outputs=outputs or None, + error=None if ok else {"kind": "integrity_mismatch", "signature": signature}) + except Exception: # noqa: BLE001 观察失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--print", action="store_true", help="打印当前目录树哈希") @@ -66,6 +140,7 @@ def main(argv: list[str] | None = None) -> int: print(tree_hash()) return 0 ok, message = check() + _record(ok) # 观察:lock 引用、目录摘要、变更文件计数;失败 error.kind=integrity_mismatch print(message) return 0 if ok else 1 diff --git a/.harness/engine/checks/mutate.py b/.harness/engine/checks/mutate.py index c83e5dc..a581329 100644 --- a/.harness/engine/checks/mutate.py +++ b/.harness/engine/checks/mutate.py @@ -25,6 +25,7 @@ from pathlib import Path from engine.checks.replay import copy_worktree +from engine.core import events from engine.core.common import ROOT, STATE_DIR, changed_files, clean_git_env, setting BASELINE = STATE_DIR / "mutation-baseline.json" @@ -174,6 +175,17 @@ def below_baseline(score: float, previous: float | None) -> bool: return previous is not None and round(score, 4) < previous +def _record(result: dict, previous: float | None, failed: bool) -> None: + """观察旁路:每个变异目标一条事件(目标、得分与基线),失败不影响原判定。""" + try: + events.emit(stage="ci", step="mutation", status="fail" if failed else "ok", + outputs={"target": result["name"], "total": result["total"], "killed": result["killed"], + "score": result["score"], "baseline": previous, + "error": result.get("error") or None}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) mode = parser.add_mutually_exclusive_group() @@ -221,6 +233,8 @@ def main(argv: list[str] | None = None) -> int: failed = True print(f" ✗ 得分低于基线 {previous:.0%}") result["score"] = round(score, 4) + _record(result, previous, # 观察:目标、得分与基线 + bool(result.get("error")) or (args.check and below_baseline(score, previous))) if args.update: for result in results: if not result.get("error"): diff --git a/.harness/engine/checks/quality.py b/.harness/engine/checks/quality.py index 1966875..8c0e463 100644 --- a/.harness/engine/checks/quality.py +++ b/.harness/engine/checks/quality.py @@ -29,7 +29,8 @@ import sys from pathlib import Path -from engine.core.common import ROOT, STATE_DIR, setting +from engine.core import events +from engine.core.common import ROOT, STATE_DIR, git, setting from engine.lang.swift import functions as swift_functions BASELINE = STATE_DIR / "quality-baseline.json" @@ -107,12 +108,34 @@ def compare(current: dict[str, int], baseline: dict[str, int]) -> list[str]: ] +def _baseline_inputs() -> list[dict] | None: + """基线引用(路径@提交 + 内容哈希);读取或定位失败只少记引用。""" + try: + return [events.file_ref("baseline", BASELINE, rev=git("rev-parse", "HEAD", cwd=ROOT, check=False))] + except Exception: # noqa: BLE001 观察旁路:引用准备失败只少记这条引用 + return None + + +def _record(current: dict[str, int], baseline: dict[str, int], inputs: list[dict] | None) -> None: + """观察旁路:每个指标一条事件(当前值、基线、是否棘轮),回归即 fail;失败不影响原判定。""" + try: + for name, value in current.items(): + regressed = name in RATCHETED and name in baseline and value > baseline[name] + events.emit(stage="ci", step="quality", status="fail" if regressed else "ok", + inputs=inputs, + outputs={"metric": name, "value": value, "baseline": baseline.get(name), + "ratcheted": name in RATCHETED}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--update", action="store_true", help="指标不高于基线时写回基线") args = parser.parse_args(argv) - current = measure() + current = measure(ROOT) baseline = json.loads(BASELINE.read_text()) if BASELINE.exists() else {} + _record(current, baseline, _baseline_inputs()) # 观察:各指标当前值/基线与回归 for name, value in current.items(): kind = "棘轮" if name in RATCHETED else "报告" print(f"{name:<20} {value:>6}(基线 {baseline.get(name, '—')},{kind})") diff --git a/.harness/engine/checks/r1_checks.py b/.harness/engine/checks/r1_checks.py index 3c89026..64bc5b2 100644 --- a/.harness/engine/checks/r1_checks.py +++ b/.harness/engine/checks/r1_checks.py @@ -7,6 +7,9 @@ - 不超规模阈值:增删行数不超过 .harness/config/autonomy.toml [size] max_lines 已有测试零改动、黄金快照零差异由 risk.py 的标记核对;变异得分不降由 build 的 harness job 在声明 R1 时运行 `mutate.py --check --changed-since`(要执行代码,放在 PR 自己的 CI 里,下降即失败)。 + +base 引用不存在(空仓库首个 PR、fresh clone 还没有 origin/main)时各项判定无法核验:判定入口返回 +「无法核验」的明确理由而不抛 git 错误,risk.py 接到后按既有降级口径取 R2。 """ from __future__ import annotations @@ -67,8 +70,20 @@ def changed_line_count(base: str, head: str, cwd: Path, exclude: list[str]) -> i return total +def base_missing(base: str, cwd: Path = ROOT) -> bool: + """base 引用无法解析为提交(空仓库首个 PR、fresh clone 还没有 origin/main)。""" + return not git("rev-parse", "--verify", "--quiet", f"{base}^{{commit}}", cwd=cwd, check=False).strip() + + +def unverifiable_reason(base: str) -> str: + """base 引用不存在时判定入口的统一理由;risk.py 接到后按既有降级口径取 R2。""" + return f"无法核验:base 引用 `{base}` 不存在" + + def violations(base: str, head: str, cwd: Path = ROOT, autonomy: dict | None = None) -> list[str]: - """返回不满足 R1 加强判定的理由;空列表表示满足。""" + """返回不满足 R1 加强判定的理由;空列表表示满足。base 引用不存在时返回「无法核验」理由,不抛 git 错误。""" + if base_missing(base, cwd): + return [unverifiable_reason(base)] autonomy = autonomy or load_autonomy() size = autonomy.get("size", {}) reasons = [] diff --git a/.harness/engine/checks/taskbook.py b/.harness/engine/checks/taskbook.py index e4752ec..754d904 100644 --- a/.harness/engine/checks/taskbook.py +++ b/.harness/engine/checks/taskbook.py @@ -40,6 +40,7 @@ from pathlib import Path from engine.checks import acceptance +from engine.core import events from engine.core.common import ROOT, STATE_DIR, git, load_rules, path_matches TASK_GLOB = "task-*.md" @@ -63,7 +64,12 @@ REQUIRED = ["task", "class", "risk", "designer", "size", "architecture", "budget", "rollback"] NEW_REF = re.compile(r"^新增[::]\s*(?P[^#\s]+)#(?P[A-Z]{1,3}\d+)$") NO_SPEC = re.compile(r"^不挂规格[::]\s*(?P\S.*)$") -PATH_TOKEN = re.compile(r"`([A-Za-z0-9_.*-]+(?:/[A-Za-z0-9_.*-]+)+|[A-Za-z0-9_-]+\.(?:py|swift|md|toml|txt|json))`") +# 步骤里的文件路径:多级路径、根目录单点与多点文件名(README.zh-CN.md,B61)都完整识别, +# 否则多点文件名被截断、不参与类别/风险的交叉核对。 +PATH_TOKEN = re.compile( + r"`([A-Za-z0-9_.*-]+(?:/[A-Za-z0-9_.*-]+)+" + r"|[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*\.(?:py|swift|md|toml|txt|json))`" +) class HeaderError(ValueError): @@ -363,12 +369,79 @@ def on_main(path: str, root: Path = ROOT) -> str | None: return None +# 问题文案前缀 → 稳定规则键(观察侧归類用,不参与判定;顺序即优先级,前缀长的在前)。 +_PROBLEM_RULES = ( + ("class 取值非法", "header.class"), + ("class ", "header.class_risk"), + ("spec_refs 中的", "acceptance.link"), + ("spec_refs", "header.spec_refs"), + ("步骤触及护栏", "steps.class"), + ("步骤触及架构级路径", "steps.architecture"), + ("步骤触及 R3 路径", "steps.risk"), + ("步骤跨", "steps.architecture"), + ("头部缺少字段", "header.required"), + ("task ", "header.task"), + ("designer", "header.designer"), + ("size", "header.size"), + ("architecture", "header.architecture"), + ("budget", "header.budget"), + ("rollback", "header.rollback"), + ("「验收」表为空", "acceptance.table"), + ("验收第", "acceptance.row"), + ("验收表挂了", "acceptance.link"), + ("缺少「目标终态」", "section.goal"), + ("中、大任务缺少", "section.required"), + ("不是 docs/plans", "path"), +) + + +def _problem_rule(error: str) -> str: + """观察旁路:把问题文案归到稳定的规则键(只用于事件计数,不参与判定;未识别的归 other)。""" + for prefix, rule in _PROBLEM_RULES: + if error.startswith(prefix): + return rule + if "origin/main" in error: + return "on_main" + if "taskbook-exempt.txt" in error: + return "exempt" + return "other" + + +def _admission_inputs(path: str, root: Path, rev: str) -> list[dict]: + """任务书引用(路径@提交 + 内容哈希);文件读不到时只带路径,不带哈希。""" + target = root / path + if not target.is_file(): + return [events.ref("taskbook", path)] + try: + return [events.file_ref("taskbook", target, rev=rev or None)] + except OSError: + return [events.ref("taskbook", path)] + + +def _record_admission(reports: list[Report], root: Path = ROOT) -> None: + """观察旁路:taskbook.admit 每份任务书一条事件(通过与问题规则计数),失败不影响原判定。""" + try: + rev = git("rev-parse", "HEAD", cwd=root, check=False) + for report in reports: + counts: dict[str, int] = {} + for error in report.errors: + rule = _problem_rule(error) + counts[rule] = counts.get(rule, 0) + 1 + events.emit(stage="ci", step="taskbook.admit", status="ok" if not report.errors else "fail", + inputs=_admission_inputs(report.path, root, rev), + outputs={"problems": len(report.errors), **counts}, + decision={"by": "taskbook", "rule": "admit", + "reason": report.errors[0] if report.errors else "合格"}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("paths", nargs="*", help="只检查这些任务书(默认全部)") parser.add_argument("--on-main", action="store_true", help="另要求与 origin/main 上的版本一致(派发前)") args = parser.parse_args(argv) - reports = check_all() + reports = check_all(ROOT) if args.paths: wanted = {Path(path).as_posix().removeprefix("./") for path in args.paths} reports = [report for report in reports if report.path in wanted] @@ -384,6 +457,7 @@ def main(argv: list[str] | None = None) -> int: for report in failed: for error in report.errors: print(f"✗ {report.path}:{error}") + _record_admission(reports, ROOT) # 观察:引用、通过与问题规则计数 return 1 if failed else 0 diff --git a/.harness/engine/checks/verify.py b/.harness/engine/checks/verify.py index 9e7e0bf..1c11229 100644 --- a/.harness/engine/checks/verify.py +++ b/.harness/engine/checks/verify.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import hashlib import json import os import platform @@ -22,6 +23,7 @@ import time from dataclasses import dataclass, field +from engine.core import events from engine.core.common import CLI, ENGINE_REL, ROOT, clean_git_env, git, setting LOG_DIR = ROOT / "build" / "verify" @@ -48,6 +50,7 @@ class Result: log: str = "" note: str = "" tail: list[str] = field(default_factory=list) + code: int | None = None # 检查命令的退出码;未运行(--skip、strict 转换)为 None def pinned_version(package: str) -> str | None: @@ -153,6 +156,49 @@ def build_checks(strict: bool = False) -> list[Check]: return [available[name] for name in names] +# 最终 Result(含 --skip 与 --strict 转换)到事件状态的映射(设计 3.3)。 +STATUS_BY_RESULT = {"pass": "ok", "fail": "fail", "skip": "skip"} + + +def _log_refs(log: str) -> dict: + """完整日志按原始字节算哈希并存为本机产物;读取或保存失败时省略对应字段,不影响判定。""" + if not log: + return {} + try: + data = (ROOT / log).read_bytes() + except OSError: + return {} + refs = {"log.sha256": hashlib.sha256(data).hexdigest(), "log.size": len(data)} + try: + stored = events.store_artifact(data) + except Exception: # noqa: BLE001 观察准备失败不影响原判定 + stored = None + if stored: + refs["log.ref"] = stored["ref"] + return refs + + +def emit_result(result: Result, info: dict, tier: str) -> None: + """每项检查的最终 Result 之后写 verify.(D029):head、档位、退出码、时长、失败签名与日志哈希。""" + try: + events.emit(stage="verify", step=f"verify.{result.name}", status=STATUS_BY_RESULT[result.status], + duration_ms=int(result.seconds * 1000), + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"exit": result.code, "signature": result.note or None, **_log_refs(result.log)}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + +def emit_summary(info: dict, tier: str, ok: bool) -> None: + """收尾写一条 verify.summary(D030):档位、通过与工作区状态。""" + try: + events.emit(stage="verify", step="verify.summary", status="ok" if ok else "fail", + inputs=[events.ref("head", info["head"]), events.ref("tier", tier)], + outputs={"tier": tier, "ok": ok, "dirty": info["dirty"]}) + except Exception: # noqa: BLE001 观察失败不影响原判定 + return + + def run_check(check: Check) -> Result: if check.requires == "macos" and not _is_macos(): return Result(check.name, "skip", note=check.why_skipped) @@ -179,9 +225,9 @@ def run_check(check: Check) -> Result: seconds = time.monotonic() - started relative = str(target.relative_to(ROOT)) if code == 0: - return Result(check.name, "pass", seconds, relative) + return Result(check.name, "pass", seconds, relative, code=code) tail = target.read_text(errors="replace").splitlines()[-TAIL_LINES:] - return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail) + return Result(check.name, "fail", seconds, relative, f"退出码 {code}", tail, code=code) def head_info() -> dict: @@ -237,6 +283,7 @@ def main(argv: list[str] | None = None) -> int: if result.status == "skip" and args.strict: result = Result(check.name, "fail", note=f"--strict 下不允许跳过({result.note})") results.append(result) + emit_result(result, info, tier_name) # 最终 Result(含 strict 转换)之后埋点,--skip 与 strict 都不漏 mark = {"pass": "✓", "fail": "✗", "skip": "-"}[result.status] extra = f" {result.note}" if result.note else "" print(f"{mark} {result.name:<15} {result.seconds:5.1f}s{extra}", flush=True) @@ -259,6 +306,7 @@ def main(argv: list[str] | None = None) -> int: "ok": not failed, } (LOG_DIR / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + emit_summary(info, tier_name, not failed) write_step_summary(results, info) if args.json: print(json.dumps(summary, ensure_ascii=False, indent=2)) diff --git a/.harness/engine/cli.py b/.harness/engine/cli.py index ea8dbd9..8c6ac54 100644 --- a/.harness/engine/cli.py +++ b/.harness/engine/cli.py @@ -7,6 +7,7 @@ import importlib import sys +import time from pathlib import Path # 让 `engine` 包可导入:引擎目录的上一级(业务仓库的 .harness/,或引擎仓库根)。 @@ -38,20 +39,70 @@ "dispatch": "engine.agents.dispatch", "review": "engine.agents.review", "review-pack": "engine.agents.review_pack", + "review-plan": "engine.agents.plan_review", "identity": "engine.agents.identity", # 报告 "metrics": "engine.reports.metrics", "weekly": "engine.reports.weekly", + # 可观测性查询(同一模块两入口;不追加自身事件,见 QUIET_COMMANDS) + "events": "engine.reports.trace:events_main", + "trace": "engine.reports.trace:trace_main", + # 审计引用复原与哈希核对(不追加自身事件;观察事件属 T404) + "audit": "engine.reports.audit", + # 告警发布(B46 设计 5;不追加通用入口事件,见 QUIET_COMMANDS) + "alert": "engine.core.alerts", # 安装与升级(只能从引擎仓库的检出运行) "install": "engine.core.install", "upgrade": "engine.core.install:upgrade_main", } +# 入口事件的阶段映射(共用合同 C1):判定入口归 verify,判级与路由归 route,派发归 dispatch, +# 评审归 review;表外的其余检查、报告与安装升级归 ci。新增入口必须落进这张表或登记例外。 +COMMAND_STAGE = { + "verify": "verify", + "integrity": "verify", + "risk": "route", + "policy": "route", + "dispatch": "dispatch", + "review": "review", + "review-pack": "review", + "review-plan": "review", +} +# 不记通用入口事件的命令:guard 只记拒绝(T104,放行不逐条记,设计 3.6);events/trace 查询、 +# audit/alert 发布各自记事件,避免递归(C1)。 +QUIET_COMMANDS = frozenset({"guard-command", "guard-git", "events", "trace", "audit", "alert"}) + def usage() -> str: return "用法:cli.py <子命令> [参数…]\n子命令:" + "、".join(COMMANDS) +def _record_dispatch(name: str, started: float, outcome) -> None: + """分派边界的观察旁路:一条 cli.<命令> 事件(时长、返回码或异常类别);永不影响原行为。 + + 观察失败(含 events 导入失败,如引擎副本损坏)静默丢弃:原 SystemExit/业务异常/返回码 + 与 stderr 逐字不变(共用合同 C0 三态等价)。 + """ + if name in QUIET_COMMANDS: + return + try: + from engine.core import events # 延迟导入:钩子等高频路径不付不必要的导入成本 + + if isinstance(outcome, SystemExit): + code = outcome.code if isinstance(outcome.code, int) else (0 if outcome.code is None else 1) + status = "ok" if code == 0 else "fail" + error = {"kind": "SystemExit"} if code else None + elif isinstance(outcome, BaseException): + code, status, error = None, "error", {"kind": type(outcome).__name__} + else: + code, status, error = outcome, ("ok" if outcome == 0 else "fail"), None + events.emit(stage=COMMAND_STAGE.get(name, "ci"), step=f"cli.{name}", status=status, + duration_ms=int((time.monotonic() - started) * 1000), + outputs={"code": code}, error=error) + except Exception: # noqa: BLE001 设计要求:观察失败不得影响原行为,也不得向 stderr 写提示 + return + + def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv if not args or args[0] in {"-h", "--help"}: @@ -62,9 +113,19 @@ def main(argv: list[str] | None = None) -> int: print(f"未知子命令:{name}\n{usage()}", file=sys.stderr) return 2 module_name, _, function = COMMANDS[name].partition(":") - module = importlib.import_module(module_name) - sys.argv = [f"cli.py {name}", *rest] - return getattr(module, function or "main")(rest) + started = time.monotonic() + try: + module = importlib.import_module(module_name) + sys.argv = [f"cli.py {name}", *rest] + code = getattr(module, function or "main")(rest) + except SystemExit as exc: + _record_dispatch(name, started, exc) # 仅记录,原样重抛 + raise + except BaseException as exc: + _record_dispatch(name, started, exc) # 业务异常同样记录后原样重抛 + raise + _record_dispatch(name, started, code) + return code if __name__ == "__main__": diff --git a/.harness/engine/core/alerts.py b/.harness/engine/core/alerts.py new file mode 100644 index 0000000..fe54a54 --- /dev/null +++ b/.harness/engine/core/alerts.py @@ -0,0 +1,340 @@ +"""共用告警 API(B46 C4,T205 定义;T403 扩展 CLI 与工作流承载):在 PR 评论或升级议题上发布带远端标记的告警。 + +渠道只有 PR/议题评论加 escalation 标签(设计 5,不新增通知渠道)。远端标记是 trace_id、NUL、 +reason 的 sha256:同键先查分页评论/带标签议题再更新,不重发;无 PR 时查/建一条升级议题。 +publish 只消费结构化既有状态,永不抛异常(C0):发布失败返回 ok=False 并留本机 alert 观察事件, +不改调用方的判定、退出码与升级。details 只含安全阶段摘要、trace 查询方式与 PR/CI/账本链接。 +本机事件库只是记录,不是去重权威:删掉本机库后重发同键告警仍更新同一条远端评论(换机同理)。 + +reason 只用 C4 稳定键。派发侧两个预警由 rules.toml 的可选 [alerts] 节启用(删除整节即全部关闭): +最后一轮 CI 在 wait 前预警(总预算 1 则首轮即预警);守卫拒绝预警按 [alerts] guard_denials_threshold +(每轮被守卫拒绝的工具调用数,一次调用多个拒绝理由算一次)触发,未配置不启用,非正或非法值禁用 +并提示一次配置错误,不改执行预算。阈值/末轮条件与安全详情组装都在本模块,dispatch 只接线。 + +T403 补 CLI 与工作流承载(F3):`alert` 子命令(cli.py 注册,QUIET_COMMANDS 不追加通用入口事件) +供评审后补之外的告警发布——CI 的 harness/auto-merge 告警 job 与本机手工调用。`--bundle` 读一个 +harness-events 事件包(C5 形状,只读不导入),包里出现该 reason 的触发证据才发布;未知失败不自动 +告警。工作流里 pull_request 触发的检查保持只读,push main 与 workflow_run 各由指定告警 job 承载 +(模板见 templates/.github/workflows/),本模块只提供发布与证据核对。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +from engine.core import events +from engine.core.common import load_rules + +LABEL = "escalation" +MARK = "" # 远端标记:trace_id、NUL、reason 的 sha256 +# C4 稳定 reason 键(新增必须先经设计方修订共用合同,不造同义短 token)。 +REASONS = frozenset(( + "integrity_failure", "review_rejected", "review_error", "dispatch_stall", "dispatch_timeout", + "dispatch_loop", "dispatch_budget", "ci_last_round", "guard_denials", "budget_exhausted", + "audit_missing_stage", "audit_anchor_mismatch", +)) +_hinted = False # 配置错误提示每进程一次(stderr 噪音控制;测试可直接复位) + + +def marker(trace_id: str, reason: str) -> str: + """远端标记值:trace_id、NUL、reason 的 sha256(C4 去重键,换机后仍靠远端标记去重)。""" + return hashlib.sha256(f"{trace_id}\0{reason}".encode()).hexdigest() + + +def dispatch_enabled(rules: dict | None = None) -> bool: + """派发预警开关:rules.toml 配置了 [alerts] 节即启用;节可选,删除整节即全部关闭。 + + 读取失败按未配置处理(观察旁路,不影响派发判定与预算)。 + """ + try: + data = rules if rules is not None else load_rules() + except Exception: # noqa: BLE001 观察旁路 + return False + return isinstance(data, dict) and "alerts" in data + + +def guard_denials_threshold(rules: dict | None = None) -> int | None: + """[alerts] guard_denials_threshold:正整数启用守卫拒绝预警;未配置返回 None(不启用); + 非正或非法值禁用该预警并提示一次配置错误,不改执行预算。""" + global _hinted + value = (rules if rules is not None else load_rules()).get("alerts", {}).get("guard_denials_threshold") + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + if not _hinted: + _hinted = True + print(f"harness:rules.toml [alerts] guard_denials_threshold 配置非法({value!r})," + "守卫拒绝预警已禁用", file=sys.stderr) + return None + return value + + +def guard_denials_exceeded(denied: int, threshold: int | None) -> bool: + """阈值条件(纯函数):轮内被拒工具调用数(绝对计数)达到阈值即触发;未配置不触发。""" + return threshold is not None and denied >= threshold + + +def is_last_ci_round(round_no: int, ci_rounds: int) -> bool: + """末轮条件(纯函数):本轮达到 CI 预算即最后一轮(总预算 1 则首轮即预警),在 wait 前触发。""" + return ci_rounds >= 1 and round_no >= ci_rounds + + +def last_round_alert(trace_id: str, task: str, round_no: int, ci_rounds: int, *, pr, gh) -> None: + """最后一轮 CI 预警:派发在每轮 wait_ci 前调用;未启用派发预警时为空操作。""" + if dispatch_enabled() and is_last_ci_round(round_no, ci_rounds): + publish(trace_id, "ci_last_round", pr=pr, task=task, gh=gh) + + +def guard_round_alert(trace_id: str, task: str, denied: int, *, pr, gh) -> None: + """守卫拒绝预警:派发在每轮执行方结束后调用;未启用或未达阈值为空操作。""" + if dispatch_enabled() and guard_denials_exceeded(denied, guard_denials_threshold()): + publish(trace_id, "guard_denials", pr=pr, task=task, gh=gh) + + +def stage_lines(trace_id: str) -> list[str]: + """安全阶段摘要(T201 时间线,只含枚举/计数/短 ID);读取或组装失败为空列表(观察旁路)。""" + try: + from engine.agents import run_timeline # 延迟导入:core 不在导入期反向依赖 agents + summary, _head = run_timeline.record_fields(trace_id) + except Exception: # noqa: BLE001 观察旁路:摘要失败只少记这一段 + return [] + lines = [] + for item in summary.get("stages") or []: + round_no = f",round {item['round']}" if item.get("round") else "" + lines.append(f"- `{item['stage']}/{item['step']}` {item['status']}" + f"(attempt {item.get('attempt', 1)}{round_no})") + return lines + + +def timeline_lines(trace_id: str, pr: int | None = None) -> list[str]: + """时间线段:trace 与时间线入口(有 PR 指向 PR/CI artifact,无 PR 指向升级议题与 trace 命令)、 + 安全阶段摘要;升级包与告警正文复用。""" + entry = ("本 PR 的 checks 与 CI artifact(事件导出,保留 90 天)" if pr is not None + else f"本升级议题与 `bin/harness trace {trace_id}`") + lines = [f"- **trace**:`{trace_id}`;时间线入口:{entry}。"] + stages = stage_lines(trace_id) + if stages: + lines += ["", "### 已发生的阶段(安全摘要)", *stages] + return lines + + +def alert_body(trace_id: str, reason: str, *, task: str | None = None, pr: int | None = None, + details: dict | None = None) -> str: + """告警正文:远端标记、标题、时间线段与 details 的附加安全行(PR/CI/账本链接由调用方给)。""" + lines = [MARK.format(marker(trace_id, reason)), f"### 告警:{task or trace_id}({reason})", "", + *timeline_lines(trace_id, pr)] + extra = (details or {}).get("lines") + if extra: + lines += ["", *[line for line in extra if isinstance(line, str)]] + return "\n".join(lines) + "\n" + + +def publish(trace_id: str, reason: str, *, pr: int | None = None, task: str | None = None, + details: dict | None = None, gh=None) -> dict: + """共用告警发布(C4):有 PR 在 PR 上评论(带 escalation 标签),无 PR 查/建一条升级议题; + 同键(trace_id+reason 的远端标记)查分页评论/议题后更新,不重发。永不抛异常:失败返回 + {ok: False, target: None, updated: False, error_kind} 并留本机 alert 事件,不改调用方的 + 判定、退出码与升级。gh 适配器没有 C4 发布接口(list_comments 缺失,如只评论的最小桩)时 + 是空操作:发布不可能成功,不发起远端调用也不留必然失败的本机事件。""" + result = {"ok": False, "target": None, "updated": False, "error_kind": None} + if gh is not None and getattr(gh, "list_comments", None) is None: + return result + try: + if reason not in REASONS: + result["error_kind"] = "unknown_reason" + else: + body = alert_body(trace_id, reason, task=task, pr=pr, details=details) + if pr is not None: + result["target"] = "pr" + existing = _marked(gh.list_comments(pr), marker(trace_id, reason), "id") + if existing is not None: + gh.edit_comment(existing, body) + result.update(ok=True, updated=True) + else: + gh.comment(pr, body, label=LABEL) + result["ok"] = True + else: + result["target"] = "issue" + number = _marked(gh.list_issues(LABEL), marker(trace_id, reason), "number") + if number is not None: + gh.edit_issue(number, body) + result.update(ok=True, updated=True) + else: + gh.create_issue(f"告警:{task or trace_id}({reason})", body, [LABEL]) + result["ok"] = True + except Exception as exc: # noqa: BLE001 发布异常不传播到原判定(C0) + result.update(target=None, updated=False, error_kind=type(exc).__name__) + _record(trace_id, reason, result) + return result + + +def _marked(items, mark: str, key: str) -> int | None: + """分页列表(PR 评论或议题)里正文带该远端标记的条目 id;没有或形状不对为 None。""" + for item in items or []: + if isinstance(item, dict) and mark in str(item.get("body") or ""): + try: + return int(item[key]) + except (KeyError, TypeError, ValueError): + return None + return None + + +def _record(trace_id: str, reason: str, result: dict, status: str | None = None) -> None: + """本机 alert 观察事件(C1:alert 自己记发布结果);emit 永不抛,组装失败也只丢弃。""" + try: + events.emit(stage="alert", step=reason, status=status or ("ok" if result["ok"] else "error"), + trace_id=trace_id, + outputs={"target": result["target"], "updated": result["updated"]}, + error={"kind": result["error_kind"]} if result["error_kind"] else None) + except Exception: # noqa: BLE001 观察旁路 + return + + +# ---- CLI(T403):cli.py COMMANDS 注册 alert;无证据跳过留 skip 事件,其余经 publish ---- + +# --bundle 证据签名:(stage, step, status) 精确匹配;audit.finding 再按 outputs.rule 区分。 +# 只映射设计 5 里由 CI 事件包承载的四类;审计的链头/锚点一致性发现(chain_invalid、ledger_mismatch) +# 与环节缺失(missing_*)同属设计 5 第七行的两个告警族。其余 reason 没有包内证据形态,配 --bundle +# 按无证据跳过(不发布)。 +BUNDLE_TRIGGERS: dict[str, tuple[str, str, str, tuple[str, ...] | None]] = { + "integrity_failure": ("verify", "integrity", "fail", None), + "budget_exhausted": ("route", "预算", "fail", None), + "audit_missing_stage": ("ci", "audit.finding", "fail", ("missing_",)), + "audit_anchor_mismatch": ("ci", "audit.finding", "fail", + ("anchor_mismatch", "chain_invalid", "ledger_mismatch")), +} + + +def bundle_evidence(bundle: dict, reason: str) -> bool: + """事件包(C5 形状)里是否有该 reason 的触发证据;包形状不对或未知失败按无证据处理。""" + trigger = BUNDLE_TRIGGERS.get(reason) + if trigger is None: + return False + stage, step, status, rules = trigger + for event in bundle.get("events") or []: + if not isinstance(event, dict): + continue + if (event.get("stage"), event.get("step"), event.get("status")) != (stage, step, status): + continue + if rules is None or str((event.get("outputs") or {}).get("rule") or "").startswith(rules): + return True + return False + + +def _load_bundle(path: Path) -> dict: + """读一个事件包:目录(含 harness-events.json)或 JSON 文件;形状不对抛 TypeError。""" + file = path / "harness-events.json" if path.is_dir() else path + bundle = json.loads(file.read_text(encoding="utf-8")) + if not isinstance(bundle, dict) or not isinstance(bundle.get("events"), list): + raise TypeError(f"{file} 不是 harness-events 事件包") + return bundle + + +class GhPublisher: + """CLI/CI 侧的 C4 发布适配器:直接调用 gh(认证取 GH_TOKEN/GH_REPO 环境),不经 bin/as-agent。 + + 派发与评审在本机以 Agent 账号发布(dispatch.GitHub 走 as-agent);工作流告警 job 持工作流自身的 + 最小写令牌,本机手工发布用操作者自己的 gh 登录。方法形状与 dispatch.GitHub 的发布接口一致, + publish 按同一接口调用两者。 + """ + + def __init__(self, cwd: Path | str | None = None): + self.cwd = str(cwd) if cwd is not None else None + + def _run(self, argv: list[str], stdin: str | None = None) -> str: + result = subprocess.run(["gh", *argv], cwd=self.cwd, input=stdin, capture_output=True, text=True, + check=False) + if result.returncode != 0: + raise RuntimeError(f"gh {' '.join(argv[:3])} 失败:{result.stderr.strip()[-500:]}") + return result.stdout.strip() + + def list_comments(self, pr: int) -> list[dict]: + raw = self._run(["api", f"repos/{{owner}}/{{repo}}/issues/{pr}/comments", "--paginate", "--slurp"]) + return [item for page in json.loads(raw or "[]") for item in page] + + def comment(self, pr: int, body: str, label: str | None = None) -> str: + url = self._run(["pr", "comment", str(pr), "--body-file", "-"], stdin=body) + if label: + self.add_label(pr, label) + return url + + def add_label(self, pr: int, label: str) -> None: + try: # 不存在才创建;已有标签不被改写(与 dispatch.GitHub 同口径) + self._run(["label", "create", label, "--color", "d93f0b"]) + except RuntimeError: + pass + self._run(["pr", "edit", str(pr), "--add-label", label]) + + def edit_comment(self, comment_id: int, body: str) -> None: + self._run(["api", "-X", "PATCH", f"repos/{{owner}}/{{repo}}/issues/comments/{comment_id}", + "--input", "-"], stdin=json.dumps({"body": body})) + + def list_issues(self, label: str) -> list[dict]: + raw = self._run(["issue", "list", "--state", "all", "--label", label, "--limit", "200", + "--json", "number,body"]) + return json.loads(raw or "[]") + + def edit_issue(self, number: int, body: str) -> None: + self._run(["issue", "edit", str(number), "--body-file", "-"], stdin=body) + + def create_issue(self, title: str, body: str, labels: list[str]) -> str: + argv = ["issue", "create", "--title", title, "--body-file", "-"] + for label in labels: + argv += ["--label", label] + return self._run(argv, stdin=body) + + +def main(argv: list[str] | None = None, gh=None) -> int: + """alert 子命令(设计 5):发布一条告警;退出码只区分参数错误(2),发布失败退出 0。 + + 发布是观察旁路:gh 失败返回 ok=False 并留本机 alert 事件,不改调用方(工作流步骤/人工 shell) + 的结论;--pr 缺省查/建一条 escalation 升级议题;--trace 缺省取当前追踪 ID(CI 为 PR 分支, + main 上 main@<短提交>);--bundle 给出 harness-events 事件包(目录或 JSON)时先核对触发证据, + 无证据不发布(留 skip 事件);未知 reason 或事件包不可用属参数错误。gh 参数供测试注入。 + """ + parser = argparse.ArgumentParser(prog="alert", description="发布告警(B46 设计 5:PR/议题评论 + escalation 标签)") + parser.add_argument("reason", help="C4 稳定 reason 键:" + "、".join(sorted(REASONS))) + parser.add_argument("--pr", type=int, help="告警落在这个 PR;缺省查/建一条 escalation 升级议题") + parser.add_argument("--trace", default="", help="追踪 ID(缺省当前分支;main 上 main@<短提交>)") + parser.add_argument("--task", default=None, help="任务编号(标题用;缺省用 trace)") + parser.add_argument("--bundle", type=Path, default=None, + help="harness-events 事件包(目录或 JSON):先核对触发证据,无证据不发布") + parser.add_argument("--json", action="store_true", help="打印结果 JSON") + args = parser.parse_args(argv) + if args.reason not in REASONS: + print(f"alert:未知 reason {args.reason!r}(可用:{'、'.join(sorted(REASONS))})", file=sys.stderr) + return 2 + trace = args.trace or events.current_trace() + if args.bundle is not None: + try: + bundle = _load_bundle(args.bundle) + except (OSError, ValueError, TypeError) as error: + print(f"alert:事件包不可用({error})", file=sys.stderr) + return 2 + if not bundle_evidence(bundle, args.reason): + _record(trace, args.reason, {"ok": False, "target": None, "updated": False, + "error_kind": None}, status="skip") + _print_result({"ok": False, "target": None, "updated": False, "error_kind": "no_evidence"}, + args.json) + return 0 + result = publish(trace, args.reason, pr=args.pr, task=args.task, gh=gh if gh is not None else GhPublisher()) + _print_result(result, args.json) + return 0 + + +def _print_result(result: dict, as_json: bool) -> None: + if as_json: + print(json.dumps(result, ensure_ascii=False)) + return + if result["ok"]: + state = "已更新" if result["updated"] else "已发布" + elif result["error_kind"] == "no_evidence": + state = "包内无触发证据,未发布" + else: + state = f"发布失败({result['error_kind']})" + print(f"alert:{state}(target={result['target']})") diff --git a/.harness/engine/core/common.py b/.harness/engine/core/common.py index f588516..295ad98 100644 --- a/.harness/engine/core/common.py +++ b/.harness/engine/core/common.py @@ -123,6 +123,14 @@ def setting(section: str, key: str, default=None, *, required: bool = False, sou return default +def ci_workflows() -> list[str]: + """判定 CI 通过与统计 CI 轮次所依据的工作流名(rules.toml [dispatch] ci_workflows,缺省 ["harness"],即模板的工作流)。""" + names = setting("dispatch", "ci_workflows", ["harness"], source="rules") + if not isinstance(names, list) or not names or not all(isinstance(name, str) and name for name in names): + raise ConfigError(".harness/config/rules.toml 的 [dispatch] ci_workflows 须是非空的工作流名列表") + return names + + # 目录约定(与项目无关的通用布局,写在引擎 README): # docs/specs/ 现役规格与验收表 docs/plans/task-*.md 任务书 # docs/runs/ 运行记录 docs/templates/ 模板 diff --git a/.harness/engine/core/events.py b/.harness/engine/core/events.py new file mode 100644 index 0000000..b32f798 --- /dev/null +++ b/.harness/engine/core/events.py @@ -0,0 +1,397 @@ +"""事件公共 API(可观测性):只包住已有的结构化结果,永不影响调用方。 + +事件不进任何判定(设计 2.5):emit / set_anchor 永不抛异常,失败向 stderr 提示一次(同一进程内) +后跳过;HARNESS_EVENTS=off 或 checks.toml [events] enabled = false 时关闭,环境变量优先。 +值只放引用、哈希、计数、枚举、时长:写入前强制隐私过滤(设计 2.4),只丢弃、不改写、不截断。 +""" + +from __future__ import annotations + +import hashlib +import os +import re +import sys +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from engine import __version__ as ENGINE_VERSION +from engine.core import events_db +from engine.core.common import git, setting + +STAGES = ("guard", "verify", "dispatch", "ci", "route", "review", "merge", "alert") +STATUSES = ("ok", "fail", "skip", "deny", "error") +ACTOR_ROLES = ("designer", "implementer", "reviewer", "approver", "engine") + +_MAX_STR = 120 +_MAX_REASON = 200 +_MAX_REF = 200 +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") +_FORBIDDEN_PREFIXES = ("/Users/", "/home/") +_DROP = object() + +_warned = False +_cleanup_warned = False +_days_warned = False + + +def _warn_once() -> None: + """同一进程内只向 stderr 提示一次事件写入失败。""" + global _warned + if not _warned: + _warned = True + print("harness:事件写入失败,已跳过(不影响本次运行)", file=sys.stderr) + + +def artifact_days() -> int: + """[events] artifact_days:本机产物保留天数,缺省 30。 + + 非法值(非整数、布尔、非正数)与配置读取失败提示一次并按 30 处理;不影响 emit 与原判定。 + """ + global _days_warned + try: + value = setting("events", "artifact_days", 30) + except Exception: # noqa: BLE001 配置损坏时按缺省处理 + return 30 + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + if not _days_warned: + _days_warned = True + print("harness:[events] artifact_days 配置非法,按缺省 30 处理", file=sys.stderr) + return 30 + return value + + +def _cleanup_warn_once(reasons: list[str]) -> None: + """同一进程内只向 stderr 提示一次清理未完成(不逐条刷屏,不影响业务)。""" + global _cleanup_warned + if not _cleanup_warned: + _cleanup_warned = True + print(f"harness:本机产物清理未完成,已跳过(不影响本次运行):{len(reasons)} 项", file=sys.stderr) + + +def _maybe_cleanup() -> None: + """每天首次 emit 顺带执行一次本机保留期清理(B40):无守护进程,跨进程以日期标志保证一天一轮。 + + 当天已有人清理则直接返回;执行与取配置的任何失败都只提示一次,不改变 emit 与业务返回。 + 清理本身不产生事件:事件/引用/锚点永不因保留期删除,也不让清理递归进 emit。 + """ + try: + now = datetime.fromisoformat(events_db._now()) + if now.tzinfo is None: + now = now.replace(tzinfo=UTC) + if not events_db.claim_cleanup_day(now.strftime("%Y-%m-%d")): + return + cutoff = now - timedelta(days=artifact_days()) + problems = events_db.run_retention_cleanup(cutoff) + except Exception as exc: # noqa: BLE001 设计要求:清理失败不得影响调用方 + _cleanup_warn_once([str(exc) or type(exc).__name__]) + return + if problems: + _cleanup_warn_once(problems) + + +def enabled() -> bool: + """事件开关:HARNESS_EVENTS=off 关闭;其次 checks.toml [events] enabled = false 关闭;缺省开启。""" + env = os.environ.get("HARNESS_EVENTS") + if env is not None: + return env.strip().lower() not in ("", "0", "off", "false") + try: + return setting("events", "enabled", True) is not False + except Exception: # noqa: BLE001 配置损坏时按缺省处理 + return True + + +def default_source() -> str: + """事件来源:真实 Actions(run_id/run_attempt/job 三键齐全)为 + "ci:::"(来源链隔离);缺键的 CI 环境兼容返回 "ci";否则 "local"。""" + if os.environ.get("CI") == "true": + run_id = os.environ.get("GITHUB_RUN_ID") + run_attempt = os.environ.get("GITHUB_RUN_ATTEMPT") + job = os.environ.get("GITHUB_JOB") + if run_id and run_attempt and job: + return f"ci:{run_id}:{run_attempt}:{job}" + return "ci" + return "local" + + +def _host_class(source: str) -> str: + """actor.host 缺省值:链来源的显示分类(隔离后的 ci::… 仍显示为 "ci",其余沿用 source)。""" + return "ci" if source.startswith("ci") else source + + +def _short_head() -> str: + return git("rev-parse", "--short=7", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) or "unknown" + + +def current_trace() -> str: + """追踪 ID:分支名;detached HEAD 为 HEAD@<7 位提交>;在 main 上为 main@<7 位提交>。 + + CI 中优先取 GITHUB_HEAD_REF(PR 分支),其次 GITHUB_REF_NAME(为 main 时同样写 main@<7 位提交>)。 + """ + if os.environ.get("CI") == "true": + head_ref = os.environ.get("GITHUB_HEAD_REF") + if head_ref: + return head_ref + ref_name = os.environ.get("GITHUB_REF_NAME") + if ref_name: + return f"main@{_short_head()}" if ref_name == "main" else ref_name + branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) + if branch in ("HEAD", "main"): + return f"{branch}@{_short_head()}" + return branch or "unknown" + + +def ref(kind: str, ref: str, *, sha256: str | None = None, size: int | None = None) -> dict: + """输入输出引用(值是引用而非内容):sha256、size 为 None 的键省略。""" + out = {"kind": kind, "ref": ref} + if sha256 is not None: + out["sha256"] = sha256 + if size is not None: + out["size"] = size + return out + + +def file_ref(kind: str, path: Path, rev: str | None = None) -> dict: + """文件引用:读内容算 sha256 与大小,ref 为仓库相对路径(POSIX),rev 非空时写成 <路径>@。""" + path = Path(path) + content = path.read_bytes() + try: + relative = path.resolve().relative_to(events_db.ROOT.resolve()).as_posix() + except ValueError: + relative = path.as_posix() + if rev: + relative = f"{relative}@{rev}" + digest = hashlib.sha256(content).hexdigest() + return {"kind": kind, "ref": relative, "sha256": digest, "size": len(content)} + + +def store_artifact(data: bytes | Path) -> dict | None: + """内容寻址保存到 artifacts/(已存在则不重复写)并登记 artifacts 表,返回引用字典。 + + Path 读取或 bytes 转换失败同样不抛异常:提示一次并返回 None;调用方把 None 放进 inputs + 时会被引用过滤器丢弃并计入 redacted,不产生半条引用。 + """ + try: + content = Path(data).read_bytes() if isinstance(data, Path) else bytes(data) + except Exception: # noqa: BLE001 设计要求:观察失败不得影响调用方 + _warn_once() + return None + digest = hashlib.sha256(content).hexdigest() + entry = {"kind": "artifact", "ref": digest, "sha256": digest, "size": len(content)} + try: + if enabled(): + events_db.save_artifact(digest, len(content), content) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + return entry + + +def _clean_str(value: str, limit: int) -> str | None: + """合规字符串原样返回;超长、形似本机路径、含换行的返回 None(丢弃,不截断)。""" + if len(value) > limit: + return None + if value.startswith(_FORBIDDEN_PREFIXES) or "C:\\" in value or "\n" in value or "\r" in value: + return None + return value + + +def _clean_scalar(value, limit: int = _MAX_STR): + """只允许 int、float、bool、None 与合规 str;其余(嵌套 dict/list 等)丢弃。""" + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + cleaned = _clean_str(value, limit) + return cleaned if cleaned is not None else _DROP + return _DROP + + +def _clean_role(value) -> str | object: + return value if value in ACTOR_ROLES else _DROP + + +def _filter_outputs(outputs) -> tuple[dict, int]: + """outputs 必须是扁平字典:键 [A-Za-z0-9_.-]{1,64},值合规标量;其余丢弃。""" + if not outputs: + return {}, 0 + if not isinstance(outputs, dict): + return {}, 1 + clean, dropped = {}, 0 + for key, value in outputs.items(): + cleaned = _clean_scalar(value) if isinstance(key, str) and _KEY_RE.match(key) else _DROP + if cleaned is _DROP: + dropped += 1 + else: + clean[key] = cleaned + return clean, dropped + + +def _filter_known(mapping, limits: dict[str, int], validators: dict[str, Callable] | None = None) -> tuple[dict, int]: + """按已定键集合过滤 dict(decision/error/actor/引用);未知键与不合规值丢弃。""" + if not mapping: + return {}, 0 + if not isinstance(mapping, dict): + return {}, 1 + clean, dropped = {}, 0 + for key, value in mapping.items(): + if key not in limits: + dropped += 1 + continue + if validators and key in validators: + cleaned = validators[key](value) + else: + cleaned = _clean_scalar(value, limits[key]) + if cleaned is _DROP: + dropped += 1 + else: + clean[key] = cleaned + return clean, dropped + + +def _filter_inputs(inputs) -> tuple[list[dict], int]: + """inputs 是引用列表;非 dict 的元素与元素内不合规的键值丢弃。""" + if not inputs: + return [], 0 + if not isinstance(inputs, list): + return [], 1 + clean, dropped = [], 0 + for item in inputs: + item_clean, item_dropped = _filter_known(item, {"kind": _MAX_STR, "ref": _MAX_REF, + "sha256": _MAX_STR, "size": _MAX_STR}) + if any(value is not None for value in item_clean.values()): + clean.append(item_clean) + dropped += item_dropped + else: + dropped += max(item_dropped, 1) # 过滤后为空的引用整条丢弃(不入表、不进哈希),至少计 1 + return clean, dropped + + +_DECISION_LIMITS = {"by": _MAX_STR, "rule": _MAX_STR, "reason": _MAX_REASON} +_ERROR_LIMITS = {"kind": _MAX_STR, "signature": _MAX_STR} +_ACTOR_LIMITS = {"role": _MAX_STR, "host": _MAX_STR, "model": _MAX_STR} + + +def _build_payload(stage: str, step: str, status: str, source: str, trace_id: str, + duration_ms, outputs, decision, error, actor, inputs) -> tuple[dict, list[dict]]: + """隐私过滤后组装存储层需要的列值(redacted 为被丢弃的键值累计)。""" + clean_outputs, n_outputs = _filter_outputs(outputs) + clean_decision, n_decision = _filter_known(decision, _DECISION_LIMITS) + clean_error, n_error = _filter_known(error, _ERROR_LIMITS) + clean_actor, n_actor = _filter_known(actor, _ACTOR_LIMITS, {"role": _clean_role}) + clean_inputs, n_inputs = _filter_inputs(inputs) + return { + "source": source, "trace_id": trace_id, "stage": stage, "step": step, "status": status, + "duration_ms": duration_ms, + "actor_role": clean_actor.get("role"), "actor_host": clean_actor.get("host"), + "model": clean_actor.get("model"), + "decision_by": clean_decision.get("by"), "decision_rule": clean_decision.get("rule"), + "decision_reason": clean_decision.get("reason"), + "error_kind": clean_error.get("kind"), "error_signature": clean_error.get("signature"), + "outputs": clean_outputs, "engine_version": ENGINE_VERSION, + "redacted": n_outputs + n_decision + n_error + n_actor + n_inputs, + }, clean_inputs + + +def emit(stage: str, step: str, status: str, *, trace_id: str | None = None, + duration_ms: int | None = None, inputs: list[dict] | None = None, outputs: dict | None = None, + decision: dict | None = None, error: dict | None = None, actor: dict | None = None, + source: str | None = None) -> int | None: + """写一个事件,返回事件 id;关闭、失败、被过滤掉整条事件时返回 None。永不抛异常。 + + trace_id 缺省 current_trace();source 缺省按环境取(真实 Actions 为 ci:::, + 其余 CI 为 "ci",本地为 "local");actor 缺省 {"role": "engine", "host": <来源的显示分类>}。 + step 先去首尾空白(含换行)再按字符串隐私规则过滤:非法整条不写,合法写清洗后的值。 + """ + try: + if not enabled(): + return None + step_text = str(step or "").strip() + if stage not in STAGES or status not in STATUSES or not step_text: + return None + if _clean_str(step_text, _MAX_STR) is None: + return None + source = source or default_source() + payload, clean_inputs = _build_payload(stage, step_text, status, source, + trace_id or current_trace(), duration_ms, + outputs, decision, error, + actor or {"role": "engine", "host": _host_class(source)}, inputs) + event_id = events_db.insert_event(payload, clean_inputs) + # 清理挂在成功的 emit 之后:主路径失败时不叠加清理提示,保持「失败只提示一次」 + _maybe_cleanup() + return event_id + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + return None + + +class Span: + """span 上下文:块内可设置 status(默认 ok)、inputs、outputs、decision、error。""" + + _FIXED_KEYS = frozenset(("trace_id", "duration_ms", "inputs", "outputs", + "decision", "error", "actor", "source")) + + def __init__(self, stage: str, step: str, fixed: dict): + self.stage = stage + self.step = step + self.fixed = fixed + self.status = "ok" + self.inputs = None + self.outputs = None + self.decision = None + self.error = None + self._start = 0.0 + + +@contextmanager +def span(stage: str, step: str, **fixed) -> Iterator[Span]: + """计时包装:退出时 emit 一个事件(duration_ms 为块耗时)。 + + 块内抛异常:status 设为 "error"、error={"kind": 异常类型名},事件写入后重新抛出原异常。 + fixed 里 emit 认可的键(trace_id、actor、source 等)原样传递;未知键丢弃—— + 观察侧失败不得传播,也不得遮盖块内的业务异常。 + """ + holder = Span(stage, step, dict(fixed)) + holder._start = time.monotonic() + try: + yield holder + except BaseException as exc: + holder.status = "error" + holder.error = {"kind": type(exc).__name__} + raise + finally: + kwargs = { + "stage": holder.stage, "step": holder.step, "status": holder.status, + "duration_ms": int((time.monotonic() - holder._start) * 1000), + "inputs": holder.inputs, "outputs": holder.outputs, + "decision": holder.decision, "error": holder.error, + } + kwargs.update({key: value for key, value in holder.fixed.items() if key in Span._FIXED_KEYS}) + emit(**kwargs) + + +def set_anchor(trace_id: str, stage: str, head_hash: str, fixed_in: str, + source: str | None = None) -> None: + """在 anchors 表登记链头锚点(fixed_in:run_record / ci_artifact / pr_comment)。永不抛异常。""" + try: + if not enabled(): + return + events_db.add_anchor(source or default_source(), trace_id, stage, head_hash, fixed_in) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + _warn_once() + + +def chain_head(trace_id: str, source: str | None = None) -> str | None: + """该 (source, trace) 最后一个事件的 hash(source 缺省同 emit);没有事件为 None。""" + try: + return events_db.chain_head(source or default_source(), trace_id) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return None + + +def verify_chain(trace_id: str | None = None, source: str | None = None) -> list[str]: + """校验哈希链,返回问题描述列表(空列表表示完好);trace_id、source 为 None 时校验全部。""" + try: + return events_db.verify(trace_id, source) + except Exception as exc: # noqa: BLE001 设计要求:链校验失败只报告,不影响调用方 + return [f"链校验失败:{exc}"] diff --git a/.harness/engine/core/events_db.py b/.harness/engine/core/events_db.py new file mode 100644 index 0000000..5a1e50d --- /dev/null +++ b/.harness/engine/core/events_db.py @@ -0,0 +1,688 @@ +"""事件存储层:SQLite 建库与迁移、带哈希链的事务写入、链校验、产物与锚点。 + +库位于 git 公共目录下的 harness/harness.db(所有 worktree 共用,位于 .git 内不会被跟踪),产物位于 +harness/artifacts/。本模块只负责存储与完整性,不做隐私过滤(调用方 engine/core/events.py 负责); +写入失败直接抛异常,由调用方吞掉。除本模块已知版本外不修改库结构:库的 user_version 较新时只读不写。 +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sqlite3 +import stat +import time +from contextlib import closing +from datetime import UTC, datetime +from itertools import count +from pathlib import Path + +from engine.core.common import ROOT, git + +SCHEMA_VERSION = 1 +BUSY_TIMEOUT_MS = 5000 +# events 表列序(INSERT 与链校验按此顺序取值)。 +EVENT_COLUMNS = ( + "id", "ts", "source", "trace_id", "seq", "prev_hash", "hash", "stage", "step", "status", + "duration_ms", "actor_role", "actor_host", "model", "decision_by", "decision_rule", + "decision_reason", "error_kind", "error_signature", "outputs", "engine_version", "redacted", +) +_REF_KEYS = ("kind", "ref", "sha256", "size") + +_CREATE_TABLES = """ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL, + source TEXT NOT NULL, + trace_id TEXT NOT NULL, + seq INTEGER NOT NULL, + prev_hash TEXT NOT NULL, + hash TEXT NOT NULL, + stage TEXT NOT NULL, + step TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER, + actor_role TEXT, + actor_host TEXT, + model TEXT, + decision_by TEXT, + decision_rule TEXT, + decision_reason TEXT, + error_kind TEXT, + error_signature TEXT, + outputs TEXT, + engine_version TEXT, + redacted INTEGER NOT NULL DEFAULT 0, + UNIQUE (source, trace_id, seq) +); +CREATE TABLE IF NOT EXISTS refs ( + event_id INTEGER NOT NULL, + direction TEXT NOT NULL, + kind TEXT, + ref TEXT, + sha256 TEXT, + size INTEGER +); +CREATE TABLE IF NOT EXISTS artifacts ( + sha256 TEXT PRIMARY KEY, + size INTEGER, + path TEXT, + created TEXT +); +CREATE TABLE IF NOT EXISTS anchors ( + id INTEGER PRIMARY KEY, + source TEXT, + trace_id TEXT, + stage TEXT, + head_hash TEXT, + fixed_in TEXT, + ts TEXT +); +""" + + +def _now() -> str: + """UTC 毫秒 ISO8601(带 Z 后缀),作为事件与锚点的时间戳。""" + return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def common_dir() -> Path | None: + """git 公共目录(`.git`,worktree 指向主仓库);不在 git 仓库里返回 None。""" + out = git("rev-parse", "--git-common-dir", cwd=ROOT, check=False, isolate=True) + if not out: + return None + path = Path(out) + if not path.is_absolute(): + path = ROOT / path + return path.resolve() + + +def db_path() -> Path | None: + directory = common_dir() + return directory / "harness" / "harness.db" if directory else None + + +def artifacts_dir() -> Path | None: + directory = common_dir() + return directory / "harness" / "artifacts" if directory else None + + +def _connect(path: Path) -> sqlite3.Connection: + """每次操作新开连接(WAL、busy_timeout),用完由调用方 contextlib.closing 关闭。""" + conn = sqlite3.connect(path, timeout=BUSY_TIMEOUT_MS / 1000, isolation_level=None) + conn.execute(f"PRAGMA busy_timeout={BUSY_TIMEOUT_MS}") + return conn + + +def _switch_to_wal(conn: sqlite3.Connection) -> None: + """切到 WAL。多进程同时建库时该 PRAGMA 不受 busy_timeout 保护,需重试等待持有者完成。""" + for attempt in range(20): + try: + mode = conn.execute("PRAGMA journal_mode").fetchone()[0] + if str(mode).lower() == "wal": + return + conn.execute("PRAGMA journal_mode=WAL") + return + except sqlite3.OperationalError: + time.sleep(0.05 * (attempt + 1)) + raise sqlite3.OperationalError("切换 WAL 超时") + + +def _schema_ready(conn: sqlite3.Connection) -> bool: + """库版本可用返回 True;比本代码已知版本新时返回 False(不写入、不修改、不报错)。""" + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + return False + if version < SCHEMA_VERSION: + _switch_to_wal(conn) + conn.executescript(_CREATE_TABLES) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + return True + + +def _normalize_ref(item: dict) -> dict: + """引用只保留四定键中非 None 的值;写入与链校验共用,保证哈希两侧一致。""" + return {key: item[key] for key in _REF_KEYS if item.get(key) is not None} + + +def _hash(row: dict, inputs: list[dict]) -> str: + """规范化 JSON(除 id、hash 外的全部列加 inputs 列表,按键排序、紧凑分隔符)的 sha256。""" + payload = {key: value for key, value in row.items() if key not in ("id", "hash")} + payload["inputs"] = inputs + text = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def insert_event(payload: dict, inputs: list[dict]) -> int | None: + """写入一个事件并接上哈希链,返回事件 id。 + + seq 分配、prev_hash 读取、插入在同一个 BEGIN IMMEDIATE 事务里,多进程并发写同一 trace 链仍完好。 + 不在 git 仓库或库版本较新时返回 None(不算失败,不提示);其余失败抛异常,由调用方吞掉。 + """ + path = db_path() + if path is None: + return None + path.parent.mkdir(parents=True, exist_ok=True) + refs = [item for item in (_normalize_ref(entry) for entry in inputs) if item] # 过滤后的空引用不入表 + outputs_text = json.dumps(payload.get("outputs") or {}, sort_keys=True, + separators=(",", ":"), ensure_ascii=False) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return None + conn.execute("BEGIN IMMEDIATE") + try: + last = conn.execute( + "SELECT seq, hash FROM events WHERE source=? AND trace_id=? ORDER BY seq DESC LIMIT 1", + (payload["source"], payload["trace_id"]), + ).fetchone() + row = { + "ts": _now(), "source": payload["source"], "trace_id": payload["trace_id"], + "seq": 1 if last is None else last[0] + 1, + "prev_hash": "" if last is None else last[1], + "stage": payload["stage"], "step": payload["step"], "status": payload["status"], + "duration_ms": payload.get("duration_ms"), + "actor_role": payload.get("actor_role"), "actor_host": payload.get("actor_host"), + "model": payload.get("model"), + "decision_by": payload.get("decision_by"), "decision_rule": payload.get("decision_rule"), + "decision_reason": payload.get("decision_reason"), + "error_kind": payload.get("error_kind"), "error_signature": payload.get("error_signature"), + "outputs": outputs_text, "engine_version": payload.get("engine_version"), + "redacted": payload.get("redacted", 0), + } + row["hash"] = _hash(row, refs) + columns = [name for name in EVENT_COLUMNS if name != "id"] + cursor = conn.execute( + f"INSERT INTO events ({','.join(columns)}) VALUES ({','.join('?' * len(columns))})", + tuple(row[name] for name in columns), + ) + event_id = cursor.lastrowid + for item in refs: + conn.execute( + "INSERT INTO refs (event_id,direction,kind,ref,sha256,size) VALUES (?,?,?,?,?,?)", + (event_id, "in", item.get("kind"), item.get("ref"), item.get("sha256"), item.get("size")), + ) + conn.execute("COMMIT") + return event_id + except Exception: + try: + conn.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + + +def chain_head(source: str, trace_id: str) -> str | None: + """该 (source, trace) 最后一个事件的 hash;没有事件或没有库返回 None。""" + path = db_path() + if path is None or not path.exists(): + return None + with closing(_connect(path)) as conn: + row = conn.execute( + "SELECT hash FROM events WHERE source=? AND trace_id=? ORDER BY seq DESC LIMIT 1", + (source, trace_id), + ).fetchone() + return row[0] if row else None + + +def verify(trace_id: str | None = None, source: str | None = None) -> list[str]: + """校验哈希链,返回问题描述列表(空列表表示完好);trace_id、source 为 None 时校验全部。 + + 先看库版本:schema 比本代码新时明确报告不可校验,不修改库、不当作完好。 + """ + path = db_path() + if path is None or not path.exists(): + return [] + problems: list[str] = [] + with closing(_connect(path)) as conn: + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > SCHEMA_VERSION: + return [f"harness.db 的 schema 版本较新(user_version={version} > {SCHEMA_VERSION}),不可校验"] + where, params = [], [] + if trace_id is not None: + where.append("trace_id=?") + params.append(trace_id) + if source is not None: + where.append("source=?") + params.append(source) + clause = f" WHERE {' AND '.join(where)}" if where else "" + groups = conn.execute("SELECT DISTINCT source, trace_id FROM events" + clause, params).fetchall() + columns = ",".join(EVENT_COLUMNS) + for src, trace in groups: + rows = conn.execute( + f"SELECT {columns} FROM events WHERE source=? AND trace_id=? ORDER BY seq", + (src, trace), + ).fetchall() + problems.extend(_verify_group(conn, src, trace, rows)) + return problems + + +def _verify_group(conn: sqlite3.Connection, source: str, trace_id: str, + rows: list[tuple]) -> list[str]: + """校验一条 (source, trace) 链:seq 连续、prev_hash 相接、内容与 hash 一致。""" + problems = [] + expected_seq = 1 + prev_hash = "" + for values in rows: + row = dict(zip(EVENT_COLUMNS, values)) + refs = conn.execute( + "SELECT kind,ref,sha256,size FROM refs WHERE event_id=? ORDER BY rowid", (row["id"],) + ).fetchall() + inputs = [_normalize_ref(dict(zip(_REF_KEYS, ref_row))) for ref_row in refs] + if row["seq"] != expected_seq: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的 seq 应为 {expected_seq},实际 {row['seq']}") + if row["prev_hash"] != prev_hash: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的 prev_hash 与前一事件 hash 不符") + if _hash(row, inputs) != row["hash"]: + problems.append(f"{source}/{trace_id}: 事件 {row['id']} 的内容与 hash 不符") + expected_seq = row["seq"] + 1 + prev_hash = row["hash"] + return problems + + +_TMP_SEQ = count() + + +def _write_artifact_file(target: Path, content: bytes) -> None: + """产物落盘:先写临时文件再原子替换;失败不留下半文件与临时残留,已有内容不重复写。 + + 临时文件名带进程号与计数,多进程/多线程并发存同一产物互不冲突;os.replace 原子生效, + 内容寻址保证同名即同内容,先后替换结果一致。 + """ + if target.exists(): + return + tmp = target.with_name(f"{target.name}.tmp-{os.getpid()}-{next(_TMP_SEQ)}") + try: + with open(tmp, "wb") as fh: + fh.write(content) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) # 替换成功后临时文件已不存在;失败时清掉残留 + + +def save_artifact(digest: str, size: int, content: bytes) -> None: + """内容寻址保存产物(临时文件写完后原子替换,已存在则不重复写)并登记 artifacts 表。""" + directory = artifacts_dir() + if directory is None: + return + directory.mkdir(parents=True, exist_ok=True) + target = directory / digest + _write_artifact_file(target, content) + path = db_path() + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return + conn.execute("INSERT OR IGNORE INTO artifacts (sha256,size,path,created) VALUES (?,?,?,?)", + (digest, size, target.as_posix(), _now())) + + +def add_anchor(source: str, trace_id: str, stage: str, head_hash: str, fixed_in: str) -> None: + """在 anchors 表登记一条链头锚点(fixed_in:run_record / ci_artifact / pr_comment)。""" + path = db_path() + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return + conn.execute("INSERT INTO anchors (source,trace_id,stage,head_hash,fixed_in,ts) VALUES (?,?,?,?,?,?)", + (source, trace_id, stage, head_hash, fixed_in, _now())) + + +# ---- 读取与批量导入(T301 events_io 的存储原语):不改表结构,user_version 保持 1 ---- + +def read_events(source: str | None = None, trace_id: str | None = None, + stage: str | None = None, status: str | None = None) -> list[dict]: + """按可选精确过滤读取事件全列(含 id),按 (source, trace_id, seq) 排序。 + + 没有库或库版本比本代码新时返回空列表(与 chain_head 的缺省行为一致:上层按无数据处理)。 + """ + path = db_path() + if path is None or not path.exists(): + return [] + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return [] + where, params = [], [] + for column, value in (("source", source), ("trace_id", trace_id), + ("stage", stage), ("status", status)): + if value is not None: + where.append(f"{column}=?") + params.append(value) + clause = f" WHERE {' AND '.join(where)}" if where else "" + rows = conn.execute( + f"SELECT {','.join(EVENT_COLUMNS)} FROM events{clause} ORDER BY source, trace_id, seq", + params, + ).fetchall() + return [dict(zip(EVENT_COLUMNS, values)) for values in rows] + + +def read_refs(event_ids: list[int]) -> dict[int, list[dict]]: + """按事件 id 批量读取规范化输入引用(保留行序,None 值键省略),返回 {event_id: [引用]}。""" + path = db_path() + if path is None or not path.exists() or not event_ids: + return {} + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return {} + placeholders = ",".join("?" * len(event_ids)) + rows = conn.execute( + f"SELECT event_id, kind, ref, sha256, size FROM refs WHERE event_id IN ({placeholders}) ORDER BY rowid", + event_ids, + ).fetchall() + grouped: dict[int, list[dict]] = {} + for event_id, *values in rows: + grouped.setdefault(event_id, []).append(_normalize_ref(dict(zip(_REF_KEYS, values)))) + return grouped + + +def read_anchors(source: str | None = None, trace_id: str | None = None) -> list[dict]: + """按可选链过滤读取锚点(不含 id,字段与表列一致),保持表内顺序。""" + path = db_path() + if path is None or not path.exists(): + return [] + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return [] + where, params = [], [] + if source is not None: + where.append("source=?") + params.append(source) + if trace_id is not None: + where.append("trace_id=?") + params.append(trace_id) + clause = f" WHERE {' AND '.join(where)}" if where else "" + rows = conn.execute( + "SELECT source, trace_id, stage, head_hash, fixed_in, ts FROM anchors" + f"{clause} ORDER BY id", + params, + ).fetchall() + keys = ("source", "trace_id", "stage", "head_hash", "fixed_in", "ts") + return [dict(zip(keys, values)) for values in rows] + + +def present_hashes(digests: list[str]) -> set[str]: + """返回这些事件 hash 中已存在库里的部分(events_io 按事件哈希幂等去重用)。""" + path = db_path() + if path is None or not path.exists() or not digests: + return set() + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return set() + placeholders = ",".join("?" * len(digests)) + rows = conn.execute(f"SELECT hash FROM events WHERE hash IN ({placeholders})", digests).fetchall() + return {row[0] for row in rows} + + +def import_rows(events: list[tuple[dict, list[dict]]], anchors: list[dict]) -> int: + """事务写入已验证的导入事件与锚点,返回写入的事件数;任一失败整体回滚。 + + 事件按调用方给定的 seq/prev_hash/hash 原样写入(不重排、不重算哈希);(source, trace_id, seq) + 撞上已有行时以 IntegrityError 抛出,不覆盖;锚点按整行幂等(六字段完全相同的不重复写)。 + 不在 git 仓库或库版本比本代码新时抛 RuntimeError,由调用方转为发现报告,不写半包。 + """ + path = db_path() + if path is None: + raise RuntimeError("不在 git 仓库,没有可导入的事件库") + path.parent.mkdir(parents=True, exist_ok=True) + anchor_keys = ("source", "trace_id", "stage", "head_hash", "fixed_in", "ts") + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + raise RuntimeError("事件库 schema 比本代码新,不导入") + conn.execute("BEGIN IMMEDIATE") + try: + columns = [name for name in EVENT_COLUMNS if name != "id"] + for row, refs in events: + cursor = conn.execute( + f"INSERT INTO events ({','.join(columns)}) VALUES ({','.join('?' * len(columns))})", + tuple(row[name] for name in columns), + ) + for item in refs: + conn.execute( + "INSERT INTO refs (event_id,direction,kind,ref,sha256,size) VALUES (?,?,?,?,?,?)", + (cursor.lastrowid, "in", item.get("kind"), item.get("ref"), + item.get("sha256"), item.get("size")), + ) + for anchor in anchors: + values = [anchor[name] for name in anchor_keys] + conn.execute( + "INSERT INTO anchors (source,trace_id,stage,head_hash,fixed_in,ts)" + " SELECT ?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM anchors" + " WHERE source=? AND trace_id=? AND stage=? AND head_hash=? AND fixed_in=? AND ts=?)", + [*values, *values], + ) + conn.execute("COMMIT") + return len(events) + except Exception: + try: + conn.execute("ROLLBACK") + except sqlite3.Error: + pass + raise + + +# ---- 保留期清理(B40):无守护进程,每天首次 emit 顺带执行,失败只报告不改变业务 ---- + +# 只删内容寻址目录里名字为 64 位十六进制的普通文件,与派发原始流 round-N.jsonl。 +_HASH_NAME = re.compile(r"[0-9a-f]{64}\Z") +_ROUND_NAME = re.compile(r"round-\d+\.jsonl\Z") + + +def claim_cleanup_day(today: str) -> bool: + """当天首次进入返回 True:以 O_CREAT|O_EXCL 原子创建 .cleanup-<日期> 标志(兼任跨进程锁)。 + + 标志放在 git 公共目录的 harness/ 下且不删除:同日再次 emit 与并发进程都得到 False。 + 时钟回退到更早日期按回退后的日期领旗;即使因此多跑一轮,删除阈值也随当前时钟前移,不会多删。 + """ + directory = common_dir() + if directory is None: + return False + try: + harness_dir = directory / "harness" + harness_dir.mkdir(parents=True, exist_ok=True) + fd = os.open(harness_dir / f".cleanup-{today}", os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + return False + except OSError: + return False + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(f"{today}\n") + return True + + +def _parse_utc(value) -> datetime | None: + """解析库里的 UTC ISO 时间戳;损坏或缺失时返回 None(调用方按无法判断跳过)。""" + try: + parsed = datetime.fromisoformat(str(value)) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def _remove_expired_artifact(directory: Path, digest: str) -> str | None: + """删除过期产物文件;返回问题描述(None 表示已删除或本就缺失)。不追随符号链接。""" + target = directory / digest + try: + info = os.lstat(target) + except FileNotFoundError: + return None # 文件已不在:过期的索引行一并清理 + except OSError as exc: + return f"产物无法确认,已跳过:{exc.strerror or exc}" + if not stat.S_ISREG(info.st_mode): + return "产物位置不是普通文件(可能是符号链接),已跳过" + try: + target.unlink() + except OSError as exc: + return f"产物删除失败,已跳过:{exc.strerror or exc}" + return None + + +def cleanup_artifacts(cutoff: datetime) -> list[str]: + """删除过期本机产物:以 UTC created 判定(早于 cutoff 才过期),文件与索引行一起清。 + + 只删内容寻址目录内名字合法的普通文件;符号链接、目录、非法名字与路径穿越一律跳过并计入 + 问题列表。events/refs/anchors 行不在此清理范围;文件已不在时仍清掉过期的索引行。 + """ + problems: list[str] = [] + path = db_path() + directory = artifacts_dir() + if path is None or directory is None or not path.exists(): + return problems + with closing(_connect(path)) as conn: + if not _schema_ready(conn): + return problems + for digest, created in conn.execute("SELECT sha256, created FROM artifacts").fetchall(): + moment = _parse_utc(created) + if moment is None or moment >= cutoff: + continue + if not _HASH_NAME.fullmatch(str(digest)): + problems.append("产物索引名字不合法,已跳过(不删除)") + continue + problem = _remove_expired_artifact(directory, str(digest)) + if problem: + problems.append(problem) + continue + conn.execute("DELETE FROM artifacts WHERE sha256=?", (digest,)) + return problems + + +def _alive(pid: int) -> bool: + """探活:系统明确 ProcessLookupError 才算不活动;权限不明等其余情形按活动处理(保守,不误删)。""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except OSError: + return True + return True + + +def _protected_tasks(slots: Path) -> tuple[set[str], list[str]]: + """收集仍活动槽位锁保护的 task 目录名,返回 (受保护集合, 问题列表)。只读锁文件,不修改派发状态。 + + 任一锁坏 JSON、缺 pid、pid 非正整数或读取失败时问题列表非空:调用方当轮整体跳过原始流清理, + 不按「读不到等于终止」删除。存活锁保护该 task 的全部 attempt 目录(F4)。 + """ + protected: set[str] = set() + problems: list[str] = [] + try: + locks = sorted(slots.glob("*.json")) + except OSError: + return set(), ["槽位锁目录无法读取"] + for lock in locks: + try: + data = json.loads(lock.read_text(encoding="utf-8") or "{}") + except (OSError, ValueError): + problems.append("槽位锁读取失败") + continue + pid = data.get("pid") if isinstance(data, dict) else None + if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0: + problems.append("槽位锁缺少有效 pid") + continue + task = data.get("task") + if not isinstance(task, str) or not task: + problems.append("槽位锁缺少 task") + continue + if _alive(pid): + protected.add(task) + return protected, problems + + +def _is_real_dir(path: Path) -> bool: + """lstat 确认是真实目录(不追随符号链接)。""" + try: + return stat.S_ISDIR(os.lstat(path).st_mode) + except OSError: + return False + + +def _delete_expired_stream(path: Path, cutoff_ts: float) -> list[str]: + """删除 mtime(UTC)早于 cutoff 的 round-N.jsonl 普通文件;删除前重新 lstat,符号链接不追随。""" + try: + info = os.lstat(path) + except FileNotFoundError: + return [] + except OSError as exc: + return [f"原始流无法确认,已跳过:{exc.strerror or exc}"] + if not stat.S_ISREG(info.st_mode): + return ["原始流不是普通文件(可能是符号链接),已跳过"] + if info.st_mtime >= cutoff_ts: + return [] + try: + path.unlink() + except OSError as exc: + return [f"原始流删除失败,已跳过:{exc.strerror or exc}"] + return [] + + +def _cleanup_attempt_streams(attempt_dir: Path, cutoff_ts: float) -> list[str]: + """删除一个 attempt 目录里的过期 round-N.jsonl;目录异常跳过并报告,符号链接不追随。""" + if not _is_real_dir(attempt_dir): + return ["attempt 目录不是普通目录,已跳过"] + try: + rounds = sorted(attempt_dir.iterdir()) + except OSError: + return ["原始流目录无法读取,已跳过"] + problems: list[str] = [] + for round_file in rounds: + if _ROUND_NAME.fullmatch(round_file.name): + problems.extend(_delete_expired_stream(round_file, cutoff_ts)) + return problems + + +def _cleanup_task_streams(task_dir: Path, protected: set[str], cutoff_ts: float) -> list[str]: + """一个任务目录的原始流清理:活动锁保护的任务连同全部 attempt 目录整目录跳过。""" + if task_dir.name in protected: + return [] + if not _is_real_dir(task_dir): + return ["原始流任务目录不是普通目录,已跳过"] + try: + attempt_dirs = sorted(task_dir.iterdir()) + except OSError: + return ["attempt 目录无法读取,已跳过"] + problems: list[str] = [] + for attempt_dir in attempt_dirs: + problems.extend(_cleanup_attempt_streams(attempt_dir, cutoff_ts)) + return problems + + +def cleanup_dispatch_streams(runs: Path, cutoff: datetime) -> list[str]: + """删除已终止运行的过期派发原始流(runs/<任务>//round-N.jsonl)。 + + runs 指向 /dispatch/runs;活动锁(pid 存活)保护该任务的全部 attempt 目录, + 无锁任务按原始流 mtime(UTC)判定。锁读取或探活不确定时当轮整体跳过并报告; + 只删名字为 round-N.jsonl 的普通文件,符号链接与目录不删、不跟随,不修改锁与派发状态。 + """ + protected, lock_problems = _protected_tasks(runs.parent / "slots") + if lock_problems: + return [*lock_problems, "原始流清理当轮跳过"] + if not _is_real_dir(runs): + return [] + cutoff_ts = cutoff.timestamp() + try: + task_dirs = sorted(runs.iterdir()) + except OSError: + return ["原始流目录无法读取"] + problems: list[str] = [] + for task_dir in task_dirs: + problems.extend(_cleanup_task_streams(task_dir, protected, cutoff_ts)) + return problems + + +def run_retention_cleanup(cutoff: datetime) -> list[str]: + """B40 保留期清理入口:过期产物文件与索引 + 终止运行的过期原始流,返回问题列表。 + + 产物按库里的 UTC created、原始流按文件 mtime(UTC)判定,都只在早于 cutoff 时删除。 + 任一异常由调用方(engine.core.events)吞掉并提示一次,不影响 emit 与业务返回。 + """ + problems = cleanup_artifacts(cutoff) + common = common_dir() + if common is not None: + problems.extend(cleanup_dispatch_streams(common / "dispatch" / "runs", cutoff)) + return problems diff --git a/.harness/engine/core/events_io.py b/.harness/engine/core/events_io.py new file mode 100644 index 0000000..999cc6c --- /dev/null +++ b/.harness/engine/core/events_io.py @@ -0,0 +1,797 @@ +"""C5 事件导出与幂等导入(可观测性 P3):EventBundle v1、查询、导出、导入、写盘与 CI 下载。 + +查询返回设计 JSON 形状的事件字典(id 是本机定位号,不入哈希,导入可重映射);导出按 +(source, trace) 整链导出完整前缀与锚点——since/stage/status 属展示过滤,筛选后的断链子集不是可 +导入包;manifest 只收录通过安全扫描的规范化 UTF-8 JSON 内容产物,原始命令输出、verify 完整日志 +与 Pi 流绝不外发,SQLite/WAL/SHM 更不在其中。导入验证 schema、来源、隐私、规范化哈希与链完整性 +后事务提交:按事件哈希去重,同 source/trace/seq 不同 hash 为冲突,坏包整体不写、逐项发现以 +findings 返回。load_ci 下载时再按 GitHub API 核对仓库、工作流路径、run/head 与包 origin(不能只 +信包自报 ci),内容只解析不执行;manifest 内容产物文件不随导入恢复(缺失由再次导出如实报告)。 +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import re +import sqlite3 +import subprocess +import urllib.parse +import zipfile +from datetime import UTC, datetime +from pathlib import Path + +from engine.core import events, events_db +from engine.core.common import git + +BUNDLE_SCHEMA_VERSION = 1 +_HASH_RE = re.compile(r"[0-9a-f]{64}\Z") +_SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") + +_LIMIT = 120 # 通用字符串上限(同 T101 过滤口径) +_REASON_LIMIT = 200 +_REF_LIMIT = 200 +_ORIGIN_LIMIT = 300 + +_BUNDLE_KEYS = ("schema_version", "origin", "events", "anchors", "artifacts", "chains", "findings") +_ORIGIN_KEYS = ("repository", "run_id", "run_attempt", "job", "workflow_ref", "head_sha", "head_branch") +_EVENT_REQUIRED = ("source", "trace_id", "seq", "prev_hash", "hash", "ts", "stage", "step", + "status", "engine_version", "redacted") +_EVENT_OPTIONAL = ("id", "duration_ms", "inputs", "outputs", "decision", "error", "actor") +_REF_KEYS = ("kind", "ref", "sha256", "size") +_ANCHOR_KEYS = ("source", "trace_id", "stage", "head_hash", "fixed_in", "ts") +_NESTED_LIMITS = { + "decision": {"by": _LIMIT, "rule": _LIMIT, "reason": _REASON_LIMIT}, + "error": {"kind": _LIMIT, "signature": _LIMIT}, + "actor": {"role": _LIMIT, "host": _LIMIT, "model": _LIMIT}, +} +_DECISION_COLUMNS = {"by": "decision_by", "rule": "decision_rule", "reason": "decision_reason"} +_ERROR_COLUMNS = {"kind": "error_kind", "signature": "error_signature"} +_ACTOR_COLUMNS = {"role": "actor_role", "host": "actor_host", "model": "model"} + +# manifest 安全扫描的禁项:换行与本机路径、会话标记(C4 词表)、shell 结构痕迹。 +_TEXT_BANS = ("\n", "\r", "/Users/", "/home/", "C:\\") +_SESSION_BANS = ("user:", "assistant:", "system:", "human:", "用户:", "助手:") +_SHELL_BANS = ("|", ";", "`", "$(", "&&", "||", ">", "<") + + +def _finding(code: str, detail: str) -> dict: + return {"code": code, "detail": detail} + + +def _is_int(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _parse_utc(value) -> datetime | None: + """解析 UTC ISO 时间戳(Z 或带时区,naive 按 UTC);不合法返回 None。""" + try: + parsed = datetime.fromisoformat(value) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _parse_since(value) -> datetime: + """query 的 since:datetime 或 ISO 字符串,统一为 aware UTC;其余类型与坏字符串抛 ValueError。""" + if isinstance(value, datetime): + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + if isinstance(value, str): + parsed = _parse_utc(value) + if parsed is not None: + return parsed + raise ValueError(f"since 无法解析为 UTC 时间(收到 {type(value).__name__})") + + +def _source_matches(candidate: str, wanted: str) -> bool: + """来源匹配:精确链(ci:::)或 local/ci/github 类型前缀。""" + return candidate == wanted or candidate.startswith(f"{wanted}:") + + +# ---- 事件形状:存储行 ↔ 设计 JSON ---- + +def _unflatten(row: dict, columns: dict[str, str]) -> dict: + """actor/decision/error 三列组还原为嵌套字典(只含非 None 键)。""" + return {key: row[name] for key, name in columns.items() if row.get(name) is not None} + + +def _row_to_event(row: dict, refs: list[dict]) -> dict: + """存储行 → 设计 JSON 形状:outputs 恢复为原紧凑 JSON 的对象,id 保留为本机定位号。""" + outputs = json.loads(row["outputs"]) if row["outputs"] else {} + return { + "id": row["id"], "ts": row["ts"], "source": row["source"], "trace_id": row["trace_id"], + "seq": row["seq"], "prev_hash": row["prev_hash"], "hash": row["hash"], + "stage": row["stage"], "step": row["step"], "status": row["status"], + "duration_ms": row["duration_ms"], "inputs": [dict(item) for item in refs], "outputs": outputs, + "decision": _unflatten(row, _DECISION_COLUMNS), "error": _unflatten(row, _ERROR_COLUMNS), + "actor": _unflatten(row, _ACTOR_COLUMNS), + "engine_version": row["engine_version"], "redacted": row["redacted"], + } + + +def _event_to_row(event: dict) -> tuple[dict, list[dict]]: + """已验证的 bundle 事件 → 存储行与规范化引用(outputs 重新落成原紧凑 JSON,None 沿用原规则)。""" + decision = event.get("decision") or {} + error = event.get("error") or {} + actor = event.get("actor") or {} + row = { + "ts": event["ts"], "source": event["source"], "trace_id": event["trace_id"], + "seq": event["seq"], "prev_hash": event["prev_hash"], "hash": event["hash"], + "stage": event["stage"], "step": event["step"], "status": event["status"], + "duration_ms": event.get("duration_ms"), + "actor_role": actor.get("role"), "actor_host": actor.get("host"), "model": actor.get("model"), + "decision_by": decision.get("by"), "decision_rule": decision.get("rule"), + "decision_reason": decision.get("reason"), + "error_kind": error.get("kind"), "error_signature": error.get("signature"), + "outputs": json.dumps(event.get("outputs") or {}, sort_keys=True, separators=(",", ":"), + ensure_ascii=False), + "engine_version": event["engine_version"], "redacted": event["redacted"], + } + refs = [events_db._normalize_ref(dict(item)) for item in (event.get("inputs") or [])] + return row, refs + + +# ---- 查询(展示视图)---- + +def query(*, trace_id: str | None = None, source: str | None = None, since=None, + stage: str | None = None, status: str | None = None) -> list[dict]: + """查询事件(展示视图,不是可导入 bundle):trace、来源、since、stage、status 过滤。 + + source 为精确链或 local/ci/github 前缀;since 为 UTC datetime 或 ISO(含边界),坏值抛 + ValueError 由观察命令转诊断;无库返回空列表,上层按无数据处理。 + """ + cutoff = _parse_since(since) if since is not None else None + rows = events_db.read_events(trace_id=trace_id, stage=stage, status=status) + refs = events_db.read_refs([row["id"] for row in rows]) + result = [] + for row in rows: + if source is not None and not _source_matches(row["source"], source): + continue + if cutoff is not None and ((moment := _parse_utc(row["ts"])) is None or moment < cutoff): + continue + result.append(_row_to_event(row, refs.get(row["id"], []))) + return result + + +# ---- 导出 ---- + +def _origin() -> dict: + """导出方环境快照:仓库、head 与真实 Actions 三键;local 导出不伪造 Actions 键。""" + origin: dict[str, str] = {} + remote = git("remote", "get-url", "origin", cwd=events_db.ROOT, check=False, isolate=True) + if remote and events._clean_str(remote, _ORIGIN_LIMIT) is not None: + origin["repository"] = remote # 本机路径形式的 remote 不外发(也是导入隐私口径) + head_sha = git("rev-parse", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) + if head_sha and _SHA_RE.fullmatch(head_sha): + origin["head_sha"] = head_sha + if os.environ.get("CI") == "true": + branch = os.environ.get("GITHUB_HEAD_REF") or os.environ.get("GITHUB_REF_NAME") or "" + run_id = os.environ.get("GITHUB_RUN_ID") + run_attempt, job = os.environ.get("GITHUB_RUN_ATTEMPT"), os.environ.get("GITHUB_JOB") + if run_id and run_attempt and job: + origin["run_id"], origin["run_attempt"], origin["job"] = run_id, run_attempt, job + workflow_ref = os.environ.get("GITHUB_WORKFLOW_REF") + if workflow_ref: + origin["workflow_ref"] = workflow_ref + else: + branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) + if branch: + origin["head_branch"] = branch + return origin + + +def export_bundle(*, trace_id: str | None = None, source: str | None = None) -> dict: + """导出 EventBundle v1:每条 (source, trace) 的完整链前缀、锚点与安全 manifest。 + + source 同 query 的匹配规则;不含展示过滤;findings 记被排除产物、不中断导出;无库各列表为空。 + """ + rows = events_db.read_events(trace_id=trace_id) + if source is not None: + rows = [row for row in rows if _source_matches(row["source"], source)] + refs = events_db.read_refs([row["id"] for row in rows]) + events_out = [_row_to_event(row, refs.get(row["id"], [])) for row in rows] + chains: list[dict] = [] + chain_keys: set[tuple[str, str]] = set() + for row in rows: + key = (row["source"], row["trace_id"]) + if key in chain_keys: + chains[-1]["head_hash"] = row["hash"] + continue + chain_keys.add(key) + chains.append({"source": row["source"], "trace_id": row["trace_id"], "head_hash": row["hash"]}) + anchors = [a for a in events_db.read_anchors() if (a["source"], a["trace_id"]) in chain_keys] + artifacts, findings = _build_manifest(events_out) + return {"schema_version": BUNDLE_SCHEMA_VERSION, "origin": _origin(), "events": events_out, + "anchors": anchors, "artifacts": artifacts, "chains": chains, "findings": findings} + + +def _harvest_digests(events_out: list[dict]) -> list[str]: + """收集指向本地产物的候选哈希:inputs 的 artifact 引用与 outputs 中的 64 位十六进制值。""" + digests: list[str] = [] + for event in events_out: + candidates = [item.get("sha256") for item in event["inputs"] if item.get("kind") == "artifact"] + candidates.extend(event["outputs"].values()) + fresh = [d for d in dict.fromkeys(candidates) if isinstance(d, str) and _HASH_RE.fullmatch(d)] + digests += [d for d in fresh if d not in digests] + return digests + + +def _artifact_bytes(path: Path) -> bytes | None: + try: + return path.read_bytes() + except OSError: + return None + + +def _safe_json_document(content: bytes) -> bool: + """内容是否为安全可发布的规范化 UTF-8 JSON 对象:可解析、字节即规范化形式、逐字段无禁项。""" + try: + parsed = json.loads(text := content.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return False + if not isinstance(parsed, dict): + return False + canonical = json.dumps(parsed, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return canonical == text and _json_strings_safe(parsed) + + +def _json_strings_safe(node) -> bool: + """递归扫描 JSON 值:字符串不得含换行/本机路径/会话标记/shell 结构,键须合规短标识。""" + if isinstance(node, str): + if any(ban in node for ban in (*_TEXT_BANS, *_SHELL_BANS)): + return False + return not any(ban in node.lower() or ban in node for ban in _SESSION_BANS) + if isinstance(node, dict): + return all(isinstance(key, str) and _KEY_RE.fullmatch(key) is not None + and _json_strings_safe(value) for key, value in node.items()) + if isinstance(node, list): + return all(_json_strings_safe(item) for item in node) + return node is None or isinstance(node, (bool, int, float)) + + +def _build_manifest(events_out: list[dict]) -> tuple[list[dict], list[dict]]: + """安全 manifest:只收录内容可核验且通过安全扫描的产物;排除项逐条写 findings(不回显内容)。""" + entries: list[dict] = [] + findings: list[dict] = [] + directory = events_db.artifacts_dir() + for digest in _harvest_digests(events_out): + label = f"产物 {digest[:12]}…" + if directory is None: + findings.append(_finding("artifact_missing", f"{label} 无本地产物目录,未列入 manifest")) + continue + content = _artifact_bytes(directory / digest) + if content is None: + findings.append(_finding("artifact_missing", f"{label} 不在本地产物目录,未列入 manifest")) + elif hashlib.sha256(content).hexdigest() != digest: + findings.append(_finding("artifact_hash", f"{label} 字节与哈希不符,未列入 manifest")) + elif not _safe_json_document(content): + findings.append(_finding("artifact_unsafe", f"{label} 不是安全的规范化 JSON,未列入 manifest")) + else: + entries.append({"sha256": digest, "size": len(content), "file": digest}) + return entries, findings + + +def write_bundle(bundle: dict, target: Path) -> None: + """把 bundle 序列化为规范化 UTF-8 JSON 文本并原子写入 target(先临时文件再替换)。 + + 序列化或写盘失败抛异常,不当成功;不校验内容(校验属于 import_bundle / 设计方验收)。 + """ + target = Path(target) + text = json.dumps(bundle, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_name(f"{target.name}.tmp-{os.getpid()}") + try: + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) + + +# ---- 导入 ---- + +def _event_prefix(event, index: int) -> str: + digest = event.get("hash") if isinstance(event, dict) else None + head = f"事件[{index}] {digest[:12]}…" if isinstance(digest, str) and _HASH_RE.fullmatch(digest) else f"事件[{index}]" + return head + + +def _validate_envelope(bundle, findings: list[dict]) -> bool: + """顶层形状:七个固定键、schema_version 只认 v1(未来版本明确拒写)、origin 与列表类型。""" + if not isinstance(bundle, dict): + findings.append(_finding("schema", "bundle 不是 JSON 对象")) + return False + if set(bundle) != set(_BUNDLE_KEYS): + missing, extra = sorted(set(_BUNDLE_KEYS) - set(bundle)), sorted(set(bundle) - set(_BUNDLE_KEYS)) + findings.append(_finding("schema", f"bundle 顶层键不符(缺 {missing},多 {extra})")) + return False + version = bundle["schema_version"] + if not _is_int(version): + findings.append(_finding("schema", "schema_version 不是整数")) + return False + if version > BUNDLE_SCHEMA_VERSION: + findings.append(_finding("future_schema", f"bundle schema 版本较新({version} > {BUNDLE_SCHEMA_VERSION}),不导入")) + return False + if version != BUNDLE_SCHEMA_VERSION: + findings.append(_finding("schema", f"未知 schema 版本 {version}")) + return False + if not _validate_origin(bundle["origin"], findings): + return False + for name in ("events", "anchors", "artifacts", "chains", "findings"): + if not isinstance(bundle[name], list): + findings.append(_finding("schema", f"bundle.{name} 不是列表")) + return False + return True + + +def _validate_origin(origin, findings: list[dict]) -> bool: + if not isinstance(origin, dict) or not set(origin) <= set(_ORIGIN_KEYS): + findings.append(_finding("schema", "bundle.origin 形状不符")) + return False + for key, value in origin.items(): + if not isinstance(value, str) or events._clean_str(value, _ORIGIN_LIMIT) is None: + findings.append(_finding("schema", f"bundle.origin.{key} 不是合规字符串")) + return False + if key == "head_sha" and _SHA_RE.fullmatch(value) is None: + findings.append(_finding("schema", "bundle.origin.head_sha 不是完整提交 SHA")) + return False + return True + + +def _valid_text(value, limit: int) -> bool: + return isinstance(value, str) and bool(value) and events._clean_str(value, limit) is not None + + +def _validate_core_fields(event: dict, prefix: str, findings: list[dict]) -> bool: + """枚举、序号、哈希、时间戳与计数列:只接受 emit 可能写出的值。""" + if event["stage"] not in events.STAGES or event["status"] not in events.STATUSES: + findings.append(_finding("schema", f"{prefix} stage/status 不在枚举内")) + return False + if not _is_int(event["seq"]) or event["seq"] < 1 or not _is_int(event["redacted"]) or event["redacted"] < 0: + findings.append(_finding("schema", f"{prefix} seq/redacted 不是非负整数")) + return False + if not (isinstance(event["prev_hash"], str) + and (event["prev_hash"] == "" or _HASH_RE.fullmatch(event["prev_hash"]))): + findings.append(_finding("schema", f"{prefix} prev_hash 形状不符")) + return False + if not isinstance(event["hash"], str) or _HASH_RE.fullmatch(event["hash"]) is None: + findings.append(_finding("schema", f"{prefix} hash 不是 64 位十六进制")) + return False + if not isinstance(event["ts"], str) or _parse_utc(event["ts"]) is None: + findings.append(_finding("schema", f"{prefix} ts 不是可解析的 UTC 时间")) + return False + if (duration := event.get("duration_ms")) is not None and not _is_int(duration): + findings.append(_finding("schema", f"{prefix} duration_ms 不是整数")) + return False + if "id" in event and (not _is_int(event["id"]) or event["id"] < 1): + findings.append(_finding("schema", f"{prefix} id 不是正整数")) + return False + return True + + +def _validate_texts(event: dict, prefix: str, findings: list[dict]) -> bool: + """自由文本列:沿用 T101 隐私过滤口径(超长、本机路径、换行即禁项),step 另要求原样无首尾空白。""" + for name, limit in (("source", _LIMIT), ("trace_id", _REF_LIMIT), ("step", _LIMIT), ("engine_version", _LIMIT)): + if not _valid_text(event[name], limit): + findings.append(_finding("privacy", f"{prefix} 的 {name} 含隐私禁项或超限")) + return False + if event["step"] != event["step"].strip(): + findings.append(_finding("schema", f"{prefix} 的 step 带首尾空白(emit 会先剥离)")) + return False + parts = event["source"].split(":") if event["source"].startswith("ci:") else [] + if parts and (len(parts) != 4 or any(not part for part in parts)): + findings.append(_finding("schema", f"{prefix} 的 source 不是 ci::: 形状")) + return False + return True + + +def _validate_scalar(value) -> bool: + """outputs 值:标量或合规短字符串(None 合法,沿用原过滤口径)。""" + return value is None or isinstance(value, (bool, int, float)) or _valid_text(value, _LIMIT) + + +def _validate_outputs(event: dict, prefix: str, findings: list[dict]) -> bool: + """outputs 扁平字典:键为合规短标识,值为标量或合规短字符串。""" + outputs = event.get("outputs") + if not isinstance(outputs, dict): + findings.append(_finding("schema", f"{prefix} 的 outputs 不是对象")) + return False + for key, value in outputs.items(): + if not isinstance(key, str) or _KEY_RE.fullmatch(key) is None or not _validate_scalar(value): + findings.append(_finding("schema", f"{prefix} 的 outputs 键值不合规")) + return False + return True + + +def _validate_nested_mapping(nested, name: str, limits: dict[str, int], prefix: str, findings: list[dict]) -> bool: + """decision/error/actor 之一:键集合固定,值合规(role 另受枚举约束)。""" + if nested is None: + return True + if not isinstance(nested, dict) or not set(nested) <= set(limits): + findings.append(_finding("schema", f"{prefix} 的 {name} 键集合不符")) + return False + for key, value in nested.items(): + if value is None: + continue + if key == "role" and value not in events.ACTOR_ROLES: + findings.append(_finding("schema", f"{prefix} 的 actor.role 不在枚举内")) + return False + if key != "role" and not _valid_text(value, limits[key]): + findings.append(_finding("privacy", f"{prefix} 的 {name}.{key} 含隐私禁项或超限")) + return False + return True + + +def _validate_nested(event: dict, prefix: str, findings: list[dict]) -> bool: + """outputs/decision/error/actor/inputs:键集合、值类型与隐私口径逐项核对,未知键拒绝。""" + if not _validate_outputs(event, prefix, findings): + return False + for name, limits in _NESTED_LIMITS.items(): + if not _validate_nested_mapping(event.get(name), name, limits, prefix, findings): + return False + return _validate_inputs(event, prefix, findings) + + +def _validate_inputs(event: dict, prefix: str, findings: list[dict]) -> bool: + """inputs 引用列表:四定键、字符串隐私口径、sha256 形状;过滤后为空的引用在真实事件里不存在。""" + inputs = event.get("inputs") + if inputs is None: + return True + if not isinstance(inputs, list): + findings.append(_finding("schema", f"{prefix} 的 inputs 不是列表")) + return False + for item in inputs: + if not isinstance(item, dict) or not set(item) <= set(_REF_KEYS): + findings.append(_finding("schema", f"{prefix} 的引用键集合不符")) + return False + for name, limit in (("kind", _LIMIT), ("ref", _REF_LIMIT), ("sha256", 64)): + if item.get(name) is not None and not _valid_text(item[name], limit): + findings.append(_finding("privacy", f"{prefix} 的引用 {name} 含隐私禁项或超限")) + return False + if item.get("sha256") is not None and _HASH_RE.fullmatch(item["sha256"]) is None: + findings.append(_finding("schema", f"{prefix} 的引用 sha256 形状不符")) + return False + if (size := item.get("size")) is not None and not (_is_int(size) and size >= 0): + # emit 只写非负 int(len(content));数字字符串/浮点经 SQLite INTEGER 亲和改型后 + # 与导入时验证的哈希口径不一致,负数则是 emit 不可能写出的形状,一律拒绝。 + findings.append(_finding("schema", f"{prefix} 的引用 size 不是非负整数")) + return False + if not any(value is not None for value in item.values()): + findings.append(_finding("schema", f"{prefix} 的引用过滤后为空(emit 会整条丢弃)")) + return False + return True + + +def _validate_event(event, index: int, findings: list[dict]) -> tuple[dict, list[dict]] | None: + """校验一个 bundle 事件并转换为(存储行, 规范化引用);不合法记 finding 返回 None。""" + prefix = _event_prefix(event, index) + if not isinstance(event, dict): + findings.append(_finding("schema", f"{prefix} 不是对象")) + return None + unknown = set(event) - set(_EVENT_REQUIRED) - set(_EVENT_OPTIONAL) + missing = [name for name in _EVENT_REQUIRED if name not in event] + if unknown or missing: + findings.append(_finding("schema", f"{prefix} 字段不符(缺 {sorted(missing)},多 {sorted(unknown)})")) + return None + if not (_validate_texts(event, prefix, findings) and _validate_core_fields(event, prefix, findings) + and _validate_nested(event, prefix, findings)): + return None + row, refs = _event_to_row(event) + if events_db._hash(row, refs) != row["hash"]: + findings.append(_finding("hash_mismatch", f"{prefix} 内容与 hash 不符(不重算掩盖)")) + return None + return row, refs + + +def _chain_heads(entries: list, findings: list[dict]) -> dict[tuple[str, str], str]: + """校验 bundle.chains 项形状并建 (source, trace) → head_hash 索引。""" + heads: dict[tuple[str, str], str] = {} + for entry in entries: + ok = (isinstance(entry, dict) and set(entry) == {"source", "trace_id", "head_hash"} + and all(_valid_text(entry[name], _REF_LIMIT) for name in ("source", "trace_id")) + and _HASH_RE.fullmatch(str(entry["head_hash"])) is not None) + if not ok: + findings.append(_finding("schema", "chains 项形状不符")) + continue + heads[(entry["source"], entry["trace_id"])] = entry["head_hash"] + return heads + + +def _check_merged_chain(key: tuple[str, str], existing: dict[int, dict], + fresh: list[tuple[dict, list[dict]]], expected_head: str, + findings: list[dict]) -> set[str] | None: + """合并已有行与新增行后核对链完整性:seq 从 1 连续、prev_hash 相接、新增时链头与 chains 一致。""" + fresh_map = {row["seq"]: row for row, _ in fresh} + merged = {seq: row["hash"] for seq, row in existing.items()} + merged.update({seq: row["hash"] for seq, row in fresh_map.items()}) + numbers = sorted(merged) + if numbers != list(range(1, len(numbers) + 1)): + findings.append(_finding("chain_gap", f"{key[0]}/{key[1]} 合并后 seq 有缺口或未从 1 开始")) + return None + previous = "" + for seq in numbers: + row = existing.get(seq) or fresh_map[seq] + if row["prev_hash"] != previous: + findings.append(_finding("chain_link", f"{key[0]}/{key[1]} seq={seq} 的 prev_hash 不相接")) + return None + previous = row["hash"] + if fresh_map and expected_head != previous: + findings.append(_finding("chain_head_mismatch", f"{key[0]}/{key[1]} 链头与 bundle.chains 不符")) + return None + return set(merged.values()) + + +def _plan_chains(bundle: dict, validated: list[tuple[dict, list[dict]]], findings: list[dict]): + """按链规划导入:包内去重、与库去重(按哈希)、seq 冲突与合并链完整性。 + + 返回 (新增 (row, refs) 列表, skipped 数, 每链合并哈希集合);有发现时不写任何行。 + """ + heads = _chain_heads(bundle["chains"], findings) + grouped: dict[tuple[str, str], list[tuple[dict, list[dict]]]] = {} + for triple in validated: + row = triple[0] + grouped.setdefault((row["source"], row["trace_id"]), []).append(triple) + if set(grouped) != set(heads): + findings.append(_finding("schema", "chains 与事件链不互相覆盖")) + return [], 0, {} + fresh_all: list[tuple[dict, list[dict]]] = [] + skipped = 0 + chain_hashes: dict[tuple[str, str], set[str]] = {} + for key in sorted(grouped): + triples = sorted(grouped[key], key=lambda item: item[0]["seq"]) + seqs = [row["seq"] for row, _ in triples] + if len(set(seqs)) != len(seqs): + findings.append(_finding("schema", f"{key[0]}/{key[1]} 包内 seq 重复")) + continue + existing = {row["seq"]: row for row in events_db.read_events(source=key[0], trace_id=key[1])} + present = events_db.present_hashes([row["hash"] for row, _ in triples]) + fresh = [triple for triple in triples if triple[0]["hash"] not in present] + skipped += len(triples) - len(fresh) + for row, _ in fresh: + hit = existing.get(row["seq"]) + if hit is not None and hit["hash"] != row["hash"]: + findings.append(_finding("seq_conflict", f"{key[0]}/{key[1]} seq={row['seq']} 已有不同 hash,不覆盖")) + hashes = _check_merged_chain(key, existing, fresh, heads[key], findings) + if hashes is not None: + chain_hashes[key] = hashes + fresh_all.extend(fresh) + return fresh_all, skipped, chain_hashes + + +def _valid_anchor(anchor, chain_hashes: dict[tuple[str, str], set[str]], findings: list[dict]) -> bool: + """锚点形状与链归属:六字段、head_hash 必须是所在链某个已合并前缀的链头。""" + if not isinstance(anchor, dict) or set(anchor) != set(_ANCHOR_KEYS): + findings.append(_finding("anchor", "锚点字段不符")) + return False + for name, limit in (("source", _REF_LIMIT), ("trace_id", _REF_LIMIT), ("stage", _LIMIT), ("fixed_in", _LIMIT)): + if not _valid_text(anchor[name], limit): + findings.append(_finding("anchor", f"锚点的 {name} 含隐私禁项、超限或为空")) + return False + if not isinstance(anchor["head_hash"], str) or _HASH_RE.fullmatch(anchor["head_hash"]) is None: + findings.append(_finding("anchor", "锚点 head_hash 不是 64 位十六进制")) + return False + if _parse_utc(anchor["ts"]) is None: + findings.append(_finding("anchor", "锚点 ts 不是可解析的 UTC 时间")) + return False + hashes = chain_hashes.get((anchor["source"], anchor["trace_id"])) + if hashes is None: + findings.append(_finding("anchor", "锚点所属链不在本包事件中")) + return False + if anchor["head_hash"] not in hashes: + findings.append(_finding("anchor_mismatch", "锚点 head_hash 不在对应链的前缀内")) + return False + return True + + +def _plan_anchors(bundle: dict, chain_hashes: dict[tuple[str, str], set[str]], findings: list[dict]) -> list[dict]: + """校验并按整行幂等挑选要写的新锚点(与库内及包内完全相同的不再写)。""" + columns = ("source", "trace_id", "stage", "head_hash", "fixed_in", "ts") + existing = {tuple(row[name] for name in columns) for row in events_db.read_anchors()} + planned: list[dict] = [] + seen: set[tuple] = set() + for anchor in bundle["anchors"]: + if not _valid_anchor(anchor, chain_hashes, findings): + continue + key = tuple(anchor[name] for name in columns) + if key in existing or key in seen: + continue + seen.add(key) + planned.append({name: anchor[name] for name in columns}) + return planned + + +def import_bundle(bundle: dict) -> dict: + """幂等导入 EventBundle,返回 {imported, skipped, findings};坏包整体不写。 + + 验证(schema、来源、隐私、哈希、链完整性)全过后才在单事务里写事件、引用与锚点;任何发现都 + 不写,findings 逐项给出 code 与不含内容的 detail。 + """ + findings: list[dict] = [] + if not _validate_envelope(bundle, findings): + return {"imported": 0, "skipped": 0, "findings": findings} + validated = [triple for index, event in enumerate(bundle["events"]) + if (triple := _validate_event(event, index, findings)) is not None] + fresh, skipped, chain_hashes = _plan_chains(bundle, validated, findings) + anchors = [] if findings else _plan_anchors(bundle, chain_hashes, findings) + if findings: + return {"imported": 0, "skipped": 0, "findings": findings} + try: + imported = events_db.import_rows(fresh, anchors) + except sqlite3.IntegrityError: + return {"imported": 0, "skipped": 0, + "findings": [_finding("seq_conflict", "写入时撞上已有事件(并发),整体未写")]} + except RuntimeError as exc: + return {"imported": 0, "skipped": 0, "findings": [_finding("storage", str(exc))]} + return {"imported": imported, "skipped": skipped, "findings": []} + + +# ---- CI 事件包下载(C5 load_ci,T302):只信 API,不信包自报;下载内容只解析、不执行 ---- + +_TRUSTED_PATHS = frozenset(f".github/workflows/{name}{ext}" + for name in ("harness", "auto-merge") for ext in (".yml", ".yaml")) +_PACKAGE_RE = re.compile(r"harness-events-(\d+)-(\d+)-(.+)\Z") + + +class GhClient: + """load_ci 缺省 gh 客户端:只读查询与 artifact 下载,失败抛 RuntimeError。""" + + def _run(self, argv: list[str]) -> bytes: + result = subprocess.run(["gh", *argv], capture_output=True, check=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", "replace").strip() or f"gh 退出码 {result.returncode}") + return result.stdout + + def pr(self, pr: int) -> dict: + data = json.loads(self._run(["pr", "view", str(pr), "--json", "headRefName,headRefOid,baseRepository"])) + base = (data.get("baseRepository") or {}).get("name") + owner = ((data.get("baseRepository") or {}).get("owner") or {}).get("login") + return {"headRefName": data.get("headRefName"), "headRefOid": data.get("headRefOid"), + "repository": f"{owner}/{base}" if owner and base else None} + + def api(self, route: str): + return json.loads(self._run(["api", route])) + + def download(self, url: str) -> bytes: + return self._run(["api", url]) + + +def _repo_identity(value: str) -> str: + """GitHub 仓库身份(owner/repo):兼容 https/ssh 远程 URL;无 URL 形状时原样参与比较。""" + match = re.search(r"[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?\Z", value.strip()) + return f"{match[1]}/{match[2]}" if match else value + + +def _list_all(client, route: str, key: str, findings: list[dict]) -> list[dict] | None: + """按 total_count 分页取全列表;API 失败、形状不符或 50 页不收敛记 api 发现并放弃。""" + items: list[dict] = [] + for page in range(1, 51): + try: + data = client.api(f"{route}&page={page}") + except (RuntimeError, ValueError) as exc: + findings.append(_finding("api", f"列出 {key} 第 {page} 页失败:{exc}")) + return None + batch = data.get(key) if isinstance(data, dict) else None + if not isinstance(batch, list): + findings.append(_finding("api", f"{key} 响应形状不符(第 {page} 页)")) + return None + items += [item for item in batch if isinstance(item, dict)] + total = data.get("total_count") + if not batch or (isinstance(total, int) and len(items) >= total): + return items + findings.append(_finding("api", f"{key} 分页 50 页未收敛,已放弃")) + return None + + +def _package_member(members: dict[str, bytes]) -> dict | None: + """zip 内唯一的 harness-events JSON 包成员(按形状初筛);没有或多个都视为坏包。""" + found: list[dict] = [] + for name, content in sorted(members.items()): + if name.lower().endswith(".json"): + try: + parsed = json.loads(content) + except ValueError: + parsed = None + if (isinstance(parsed, dict) and parsed.get("schema_version") == BUNDLE_SCHEMA_VERSION + and "origin" in parsed and isinstance(parsed.get("events"), list)): + found.append(parsed) + return found[0] if len(found) == 1 else None + + +def _import_package(data: bytes, match: re.Match, branch: str, head: str, repo: str, + findings: list[dict]) -> tuple[int, int] | None: + """一个下载完成的包:zip→bundle→origin/来源核对→幂等导入;坏包记发现返回 None。""" + run_id, attempt, job = match.groups() + label = f"包 {run_id}/{attempt}/{job}" + try: + archive = zipfile.ZipFile(io.BytesIO(data)) + members = {name: archive.read(name) for name in archive.namelist() if not name.endswith("/")} + except zipfile.BadZipFile: + findings.append(_finding("package_corrupt", f"{label} 不是有效的 zip")) + return None + bundle = _package_member(members) + if bundle is None: + findings.append(_finding("package_corrupt", f"{label} 找不到唯一的 harness-events JSON")) + return None + origin = bundle.get("origin") if isinstance(bundle.get("origin"), dict) else {} + expected = {"run_id": run_id, "run_attempt": attempt, "job": job, "head_sha": head, "head_branch": branch} + wrong = [key for key, want in expected.items() if str(origin.get(key)) != want] + if "repository" not in origin or _repo_identity(str(origin["repository"])) != _repo_identity(repo): + wrong.append("repository") + if wrong: + findings.append(_finding("origin_mismatch", f"{label} origin 与 API 不符({','.join(wrong)})")) + return None + if any(event.get("source") != f"ci:{run_id}:{attempt}:{job}" for event in bundle["events"]): + findings.append(_finding("source_mismatch", f"{label} 事件来源不是 ci:{run_id}:{attempt}:{job}")) + return None + result = import_bundle(bundle) + findings.extend(result["findings"]) + return result["imported"], result["skipped"] + + +def _download_run(client, run: dict, packages: list[dict], branch: str, head: str, repo: str, + findings: list[dict]) -> tuple[int, int]: + """下载并导入一个运行的事件包,返回(新增,跳过);过期/名字不符/下载失败逐项记发现。""" + imported = skipped = 0 + for item in packages: + name = str(item.get("name")) + if item.get("expired"): + findings.append(_finding("artifact_expired", f"artifact {name} 已过期,未下载")) + continue + match = _PACKAGE_RE.fullmatch(name) + if match[1] != str(run.get("id")) or match[2] != str(run.get("run_attempt")): + findings.append(_finding("artifact_name", f"artifact {name} 物理名与运行不符,未下载")) + continue + try: + data = client.download(str(item.get("archive_download_url"))) + except (RuntimeError, ValueError) as exc: + findings.append(_finding("api", f"下载 {name} 失败:{exc}")) + continue + if result := _import_package(data, match, branch, head, repo, findings): + imported, skipped = imported + result[0], skipped + result[1] + return imported, skipped + + +def load_ci(pr: int, *, head: str | None = None, gh=None) -> dict: + """下载 PR 关联的 CI 事件包(harness 与 auto-merge 多 job/attempt)并幂等导入(C5)。 + + 只信 API:仓库、工作流路径、run/head 与物理名逐项核对包 origin 与事件 source;过期/缺失/ + 坏包/其他 head 的运行逐项记 findings;分页不收敛明确报告。 + """ + findings: list[dict] = [] + client = GhClient() if gh is None else gh + try: + info = client.pr(pr) + branch, resolved, repo = info.get("headRefName"), head or info.get("headRefOid"), info.get("repository") + except (RuntimeError, ValueError, AttributeError) as exc: + return {"imported": 0, "skipped": 0, "findings": [_finding("api", f"查询 PR {pr} 失败:{exc}")]} + if not (isinstance(branch, str) and branch and isinstance(resolved, str) + and _SHA_RE.fullmatch(resolved) and isinstance(repo, str) and repo): + return {"imported": 0, "skipped": 0, + "findings": [_finding("api", f"PR {pr} 的 head/仓库信息缺失或形状不符")]} + route = f"repos/{repo}/actions/runs?branch={urllib.parse.quote(branch, safe='')}&per_page=100" + runs = _list_all(client, route, "workflow_runs", findings) + if runs is None: + return {"imported": 0, "skipped": 0, "findings": findings} + trusted = [run for run in runs if run.get("path") in _TRUSTED_PATHS] + stale = sorted({str(run.get("headSha")) for run in trusted if run.get("headSha") != resolved}) + if stale: + findings.append(_finding("head_mismatch", f"{len(stale)} 个其他 head 的运行未导入" + f"(只导入 head {resolved[:7]}…)")) + imported = skipped = 0 + for run in sorted(trusted, key=lambda item: str(item.get("id"))): + if run.get("headSha") != resolved: + continue + artifacts = _list_all(client, f"repos/{repo}/actions/runs/{run.get('id')}/artifacts?per_page=100", + "artifacts", findings) + if artifacts is None: + continue + packages = [item for item in artifacts if _PACKAGE_RE.fullmatch(str(item.get("name") or ""))] + if not packages: + findings.append(_finding("artifact_missing", f"运行 {run.get('id')} 没有 harness-events 事件包")) + got = _download_run(client, run, packages, branch, resolved, repo, findings) + imported, skipped = imported + got[0], skipped + got[1] + return {"imported": imported, "skipped": skipped, "findings": findings} diff --git a/.harness/engine/guards/command_guard.py b/.harness/engine/guards/command_guard.py index e263bb9..a0757f9 100644 --- a/.harness/engine/guards/command_guard.py +++ b/.harness/engine/guards/command_guard.py @@ -8,6 +8,8 @@ --format json {"command": "..."}、{"file_path": "..."} 或 {"tool_name": "..."}(OpenCode 插件用) --format plain 整段 stdin 就是命令 拒绝时退出码 2,理由写 stderr(Claude Code 会把它反馈给模型);放行退出码 0。 +拒绝在本报告边界逐条写 guard 事件(规则键、角色、工具类别、安全相对目标;理由与命令全文不进事件), +放行不记也不计数(设计 3.6,执行环节的放行数由 T105 从工具完成事件汇总)。 --role implementer 另外禁止编辑判定器与护栏(CI、hooks、harness、Agent 配置):执行者改不了判定器; 也不能编辑合同(任务书与现役规格)与运行记录(docs/runs/),不能关闭、改动议题与标签。 @@ -22,61 +24,74 @@ import sys from pathlib import Path -from engine.core import shell_structure +from engine.core import events, shell_structure from engine.core.common import path_matches, setting +from engine.guards import shell_structure as guard_structure -# (模式, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 -COMMAND_RULES: list[tuple[str, str]] = [ +# (模式, 规则键, 理由)。按整条命令匹配,`&&`、`;`、管道串起来的命令同样生效。 +# 规则键是拒绝事件里的稳定标识(设计 3.6):理由文案可能调整且可能含命令字样,事件只记键、不复制理由。 +COMMAND_RULES: list[tuple[str, str, str]] = [ ( r"\bgit\s+filter-(repo|branch)\b|(^|[;&|(]|&&)\s*(\S*/)?git-filter-repo\b", + "history_rewrite", "改写历史(v0.8.0 曾因此改写 main 与 18 个 tag)", ), # 结尾用「后面不是词字符」而非空白:解释器代码里常紧跟引号或括号(os.system('git push --force'))。 - (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "强制推送:已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s\+\S+", "强制推送(+refspec):已推送的历史不改写"), - (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "批量推送(--mirror/--all/--tags)"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "直接推送 main:请推功能分支并开 PR"), - (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "推送 tag:推 tag 会触发发布,只能由用户执行"), - (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "创建、移动或删除 tag:发版由用户执行"), - (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "直接改写 main 或 tag 引用"), - (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "--no-verify 跳过 git 守卫"), - (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "-n(--no-verify)跳过 git 守卫"), + (r"\bgit\s+push\b[^;&|]*\s(--force(-with-lease)?(=\S*)?|-f)(?![\w-])", "force_push", + "强制推送:已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s\+\S+", "force_push_refspec", "强制推送(+refspec):已推送的历史不改写"), + (r"\bgit\s+push\b[^;&|]*\s--(mirror|all|tags)\b", "batch_push", "批量推送(--mirror/--all/--tags)"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/heads/)?main(\s|$)", "push_main", "直接推送 main:请推功能分支并开 PR"), + (r"\bgit\s+push\b[^;&|]*\s(\S*:)?(refs/tags/\S+|v\d[\w.-]*)(\s|$)", "push_tag", + "推送 tag:推 tag 会触发发布,只能由用户执行"), + (r"\bgit\s+tag\s+(?!(-l|--list|-n\d*|--contains|--points-at|--sort)\b)\S", "tag_write", + "创建、移动或删除 tag:发版由用户执行"), + (r"\bgit\s+update-ref\b[^;&|]*(refs/heads/main|refs/tags/)", "update_ref_protected", "直接改写 main 或 tag 引用"), + (r"\bgit\s+(commit|push|merge|rebase|am)\b[^;&|]*\s--no-verify\b", "no_verify", "--no-verify 跳过 git 守卫"), + (r"\bgit\s+(commit|merge)\b[^;&|]*\s-[a-zA-Z]*n[a-zA-Z]*(\s|$)", "no_verify_short", "-n(--no-verify)跳过 git 守卫"), ( # 只拦设置或取消;`git config --get` 等只读查询放行(此前任何提及都拦,误伤自检)。 ( r"(?i)-c\s*core\.hookspath=|\bgit\s+config\b(?![^;&|]*--(get|get-all|get-regexp|list|show-origin)\b)" r"[^;&|]*core\.hookspath" ), + "hookspath_write", "修改 core.hooksPath 会绕过 git 守卫", ), - # 覆盖变量:出现变量名或其后半截即拒(拼接如 ${P}_ALLOW_TAG 也能命中;评审 PR7-R6)。 - (r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b", "覆盖变量只供人使用,Agent 不能自行放开守卫"), - (r"\bgit\s+reset\s+[^;&|]*--hard\b", "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), + # 覆盖变量(override_var)不在此表:按 shell 结构只在真赋值位置判定,见 engine/guards/shell_structure(B60), + # 避免 heredoc 正文与引号内的文字被误当赋值。 + (r"\bgit\s+reset\s+[^;&|]*--hard\b", "reset_hard", + "git reset --hard 会丢弃未提交的改动(v0.8.0 X1 的修复就这样丢失);先提交或 stash"), # 解析失败时的兜底(宁可误报):-x 且没有任何 -e 排除即拒;能解析时由 shell_structure 按 [runtime] preserve 精确判断。 - (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), + (r"\bgit\s+clean\b(?=[^;&|]*\s-\w*[xX])(?![^;&|]*\s-e\s)", "clean_keep", + "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径"), ( r"\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\b[^;&|]*?\s(/(?!tmp/|private/tmp/|var/folders/)|~|\$HOME|\.\.)", + "rm_outside", "递归删除工作区以外的路径(临时目录 /tmp 除外)", ), # 只读的 list、view 放行(审计 G11);创建、编辑、上传、删除发布由用户执行。 - (r"\bgh\s+release\b(?!\s+(list|view)\b)", shell_structure.RELEASE), + (r"\bgh\s+release\b(?!\s+(list|view)\b)", "release_write", shell_structure.RELEASE), # 议题与标签是逃逸、抽审、升级的登记(设计 14.3):登记只会让系统更保守,删除或撤标签会让误差预算失真。 - (r"\bgh\s+issue\s+delete\b", shell_structure.ISSUE_DELETE), + (r"\bgh\s+issue\s+delete\b", "issue_delete", shell_structure.ISSUE_DELETE), ( r"\bgh\s+(issue|pr)\s+edit\b[^;&|]*--remove-label[\s=][^;&|]*\b(escape|audit|escalation|budget-exceeded)\b", + "label_erase", shell_structure.LABEL_ERASE, ), ( r"\bgh\s+label\s+(delete|edit)\b[^;&|]*\b(escape|audit|escalation|budget-exceeded|class:K\d)\b", + "label_erase", shell_structure.LABEL_ERASE, ), - (r"\bgh\s+(run|repo)\s+delete\b", "删除 CI 记录或远端资源会销毁证据"), + (r"\bgh\s+(run|repo)\s+delete\b", "delete_remote", "删除 CI 记录或远端资源会销毁证据"), ( # gh api / curl 的写请求可绕过分支保护:PATCH 改远端 ref 即服务端强推,PUT contents 直写 main(评审 PR7-R6)。 ( r"(?i)\bgh\s+api\b[^;&|]*\s(-X\s*|--method[\s=]+)(POST|PUT|PATCH|DELETE)\b" r"|\bgh\s+api\b[^;&|]*\s(-f|-F|--field|--raw-field|--input)(\s|=)" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ( @@ -87,32 +102,35 @@ r"(?=[^;&|]*\s(-X\s*|--request[\s=]+)(?i:POST|PUT|PATCH|DELETE)\b" r"|[^;&|]*\s(-[dFT]\S*|--data\S*|--form\S*|--upload-file|--json)(\s|=|$))" ), + "api_write", "GitHub API 写操作(改 ref、写文件、删资源)会绕过分支保护与评审;需要时交给用户", ), ] # 合并 PR 不归 Agent(用户 2026-09-25 决定 D4):R0/R1 由仓库 auto-merge 在门禁全绿后合并,R2 以上由用户合并。 MERGE_REASON = "合并 PR:R0/R1 由仓库 auto-merge 合并,R2 及以上由用户合并,Agent 不自行合并(方案 §13 D4)" -COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", MERGE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+merge\b", "merge_pr", MERGE_REASON)) # 批准也不归 Agent(D3):ruleset 要求非推送者批准最后一次推送,Agent 批准就等于替用户放行。 APPROVE_REASON = "批准 PR:合并前须由非推送者批准;R0/R1 由 auto-merge 的 App 批准,R2 及以上由用户批准,Agent 不批准(方案 §13 D3)" -COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", APPROVE_REASON)) +COMMAND_RULES.append((r"\bgh\s+pr\s+review\b[^;&|]*\s(-a|--approve)(\s|=|$)", "approve_pr", APPROVE_REASON)) # 执行者另外不能关闭、重开、改动议题(含改标签):逃逸与抽审由设计评审方处理(设计 14.3)。 ISSUE_REASON = shell_structure.ISSUE_IMPLEMENTER -IMPLEMENTER_COMMAND_RULES: list[tuple[str, str]] = [ - (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", ISSUE_REASON), - (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", ISSUE_REASON), - (r"\bgh\s+label\b(?!\s+list\b)", ISSUE_REASON), +IMPLEMENTER_COMMAND_RULES: list[tuple[str, str, str]] = [ + (r"\bgh\s+issue\s+(close|reopen|edit|transfer|lock|unlock|pin|unpin|delete)\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+pr\s+edit\b[^;&|]*--(add|remove)-label\b", "issue_write", ISSUE_REASON), + (r"\bgh\s+label\b(?!\s+list\b)", "issue_write", ISSUE_REASON), ] # 按工具名拒绝的 MCP 等非命令工具(如 GitHub MCP 的 merge_pull_request、enable_pr_auto_merge)。 -TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", shell_structure.LABEL_ERASE), - (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", MERGE_REASON), +TOOL_RULES: list[tuple[str, str, str]] = [ + (r"(?i)(^|[_.])(delete_issue|delete_label|label_write|update_label)$", "tool_label_erase", + shell_structure.LABEL_ERASE), + (r"(?i)(^|[_.])(merge_pull_request|enable_pr_auto_merge|merge_pr)$", "tool_merge_pr", MERGE_REASON), # MCP 的评审工具按名字看不出是批准还是评论,一律拒绝;Agent 的评审意见写进报告或用 gh pr comment。 ( ( r"(?i)(^|[_.])(pull_request_review_write|create_pull_request_review|create_and_submit_pull_request_review" r"|submit_pending_pull_request_review|approve_pull_request|approve_pr)$" ), + "tool_approve_pr", APPROVE_REASON, ), ] @@ -141,19 +159,68 @@ def protected_for_implementer() -> list[str]: CONTRACTS_FOR_IMPLEMENTER = ["docs/plans/task-*.md", "docs/specs/**"] # 运行记录由派发脚本生成,是度量与审计的输入(设计 10.1),执行方不能编辑。 RUNS_FOR_IMPLEMENTER = ["docs/runs/**"] -IMPLEMENTER_TOOL_RULES: list[tuple[str, str]] = [ - (r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", ISSUE_REASON), +IMPLEMENTER_TOOL_RULES: list[tuple[str, str, str]] = [ + ( + r"(?i)(^|[_.])(issue_write|update_issue|close_issue|reopen_issue|add_issue_labels?|remove_issue_labels?)$", + "tool_issue_write", + ISSUE_REASON, + ), ] -_COMPILED = [(re.compile(pattern), reason) for pattern, reason in COMMAND_RULES] -_TOOLS = [(re.compile(pattern), reason) for pattern, reason in TOOL_RULES] -_IMPLEMENTER_COMMANDS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_COMMAND_RULES] -_IMPLEMENTER_TOOLS = [(re.compile(pattern), reason) for pattern, reason in IMPLEMENTER_TOOL_RULES] +_COMPILED = [(re.compile(pattern), key, reason) for pattern, key, reason in COMMAND_RULES] +_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in TOOL_RULES] +_IMPLEMENTER_COMMANDS = [(re.compile(pattern), key, reason) + for pattern, key, reason in IMPLEMENTER_COMMAND_RULES] +_IMPLEMENTER_TOOLS = [(re.compile(pattern), key, reason) for pattern, key, reason in IMPLEMENTER_TOOL_RULES] + +# 结构化规则(shell_structure)给出的理由 → 规则键;随后命令字符串规则表覆盖同名理由(同类拒绝共用一键)。 +_COMMAND_REASON_KEYS: dict[str, str] = { + shell_structure.FILTER: "history_rewrite", + shell_structure.FORCE: "force_push", + shell_structure.FORCE_REFSPEC: "force_push_refspec", + shell_structure.BATCH: "batch_push", + shell_structure.PUSH_MAIN: "push_main", + shell_structure.PUSH_TAG: "push_tag", + shell_structure.TAG: "tag_write", + shell_structure.UPDATE_REF: "update_ref_protected", + shell_structure.NO_VERIFY: "no_verify", + shell_structure.NO_VERIFY_SHORT: "no_verify_short", + shell_structure.HOOKS_PATH: "hookspath_write", + shell_structure.OVERRIDE: "override_var", + shell_structure.RESET_HARD: "reset_hard", + shell_structure.RM_OUTSIDE: "rm_outside", + shell_structure.RELEASE: "release_write", + shell_structure.ISSUE_DELETE: "issue_delete", + shell_structure.LABEL_ERASE: "label_erase", + shell_structure.ISSUE_IMPLEMENTER: "issue_write", + shell_structure.DELETE_REMOTE: "delete_remote", + shell_structure.API_WRITE: "api_write", + shell_structure.MERGE: "merge_pr", + shell_structure.APPROVE: "approve_pr", +} +for _rules in (COMMAND_RULES, IMPLEMENTER_COMMAND_RULES): + for _pattern, _key, _reason in _rules: + _COMMAND_REASON_KEYS[_reason] = _key +_TOOL_REASON_KEYS = {reason: key for _pattern, key, reason in TOOL_RULES + IMPLEMENTER_TOOL_RULES} +# 带填充值的理由(clean_keep 的保留路径清单)按前缀取键。 +_REASON_KEY_PREFIXES = [(shell_structure.CLEAN_X.split("{paths}")[0], "clean_keep")] + + +def rule_key(reason: str, category: str = "command") -> str: + """拒绝理由 → 稳定规则键。理由文本可能含命令、路径或会话正文,永远不进事件; + 未登记的理由(如将来新增规则暂未配键)统一记 guard_denial,只影响粒度、不影响拒绝本身。""" + table = _TOOL_REASON_KEYS if category == "tool" else _COMMAND_REASON_KEYS + if reason in table: + return table[reason] + for prefix, key in _REASON_KEY_PREFIXES: + if reason.startswith(prefix): + return key + return "guard_denial" def check_tool(name: str, role: str = "designer") -> list[str]: rules = _TOOLS + (_IMPLEMENTER_TOOLS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(name)] + return [reason for pattern, _key, reason in rules if pattern.search(name)] def check_command(command: str, role: str = "designer") -> list[str]: @@ -162,17 +229,21 @@ def check_command(command: str, role: str = "designer") -> list[str]: reasons = shell_structure.check(command, lambda text: check_command_text(text, role), role) except ValueError: # 引号不配对等无法解析:退回字符串规则(宁可误报) reasons = check_command_text(command, role) + # 覆盖变量单独按结构判定(B60):heredoc 正文与引号内文字是数据,只在真赋值位置拒绝。 + reasons += guard_structure.check(command) return list(dict.fromkeys(reasons)) def check_command_text(command: str, role: str = "designer") -> list[str]: """字符串规则:整段文本里出现危险字样即拒。只用于会执行代码的文本(解释器代码、交给 shell 的 stdin) - 与无法解析的命令。""" + 与无法解析的命令。覆盖变量不在此列:它按 shell 结构判定(guard_structure.check), + 否则解释器代码与 stdin 正文里的文字会被误当赋值(B60)。""" normalized = " " + re.sub(r"\s+", " ", command.strip()) + " " # 再检查一遍去掉引号与反斜杠的形式:`"HARNESS"'_ALLOW_TAG'`、`HAR\\NESS_...` 这类拆写(PR7-R6)。 unquoted = re.sub(r"[\"'\\]", "", normalized) rules = _COMPILED + (_IMPLEMENTER_COMMANDS if role == "implementer" else []) - return [reason for pattern, reason in rules if pattern.search(normalized) or pattern.search(unquoted)] + return [reason for pattern, _key, reason in rules + if pattern.search(normalized) or pattern.search(unquoted)] def _relative(path: str, root: Path) -> str: @@ -185,22 +256,66 @@ def _relative(path: str, root: Path) -> str: return candidate.as_posix().removeprefix("./") -def check_edit(path: str, role: str, root: Path) -> list[str]: +def _edit_denials(path: str, role: str, root: Path) -> list[tuple[str, str]]: + """编辑拒绝的 (规则键, 理由)。理由含目标路径,事件里只取键与安全目标(见 _safe_target)。""" if role != "implementer": return [] relative = _relative(path, root) pattern = path_matches(relative, protected_for_implementer()) if pattern and relative not in IMPLEMENTER_EDITABLE: - return [f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方"] + return [("edit_protected", f"执行者不能编辑判定器与护栏({relative} 命中 {pattern});需要改动请升级给评审方")] pattern = path_matches(relative, CONTRACTS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包"] + return [("edit_contract", f"执行者不能编辑合同({relative} 命中 {pattern}):任务书与规格由设计方维护,需要改时写升级包")] pattern = path_matches(relative, RUNS_FOR_IMPLEMENTER) if pattern: - return [f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成"] + return [("edit_runs", f"执行者不能编辑运行记录({relative} 命中 {pattern}):运行记录由派发脚本生成")] return [] +def check_edit(path: str, role: str, root: Path) -> list[str]: + return [reason for _key, reason in _edit_denials(path, role, root)] + + +def _safe_target(path: str, root: Path) -> str | None: + """编辑拒绝的仓库相对目标:只在能安全归化为仓库内相对路径时返回,否则省略(None)。 + + 不把本机绝对路径清洗成貌似可信的相对路径:根外绝对路径、逃出仓库的相对路径一律省略。 + """ + candidate = Path(path) + try: + if candidate.is_absolute(): + return candidate.resolve().relative_to(root.resolve()).as_posix() + relative = candidate.as_posix().removeprefix("./") + except (ValueError, OSError): + return None + parts = Path(relative).parts + if not relative or parts in ((".",), ("/",)) or ".." in parts: + return None + return relative + + +def denial_entries(payload: dict, role: str = "designer", + root: Path | None = None) -> list[tuple[str, str, str, str | None]]: + """报告边界的拒绝条目 (理由, 类别, 规则键, 安全相对目标):evaluate 的理由序列即本表的理由投影。 + + 类别为 tool / command / edit;目标只对编辑拒绝从载荷的文件路径安全提取,命令拒绝不解析命令文本、 + 一律省略。理由可能含命令、路径或会话正文,调用方不得把它写进事件。 + """ + root = root or Path.cwd() + command, path = extract(payload) + entries: list[tuple[str, str, str, str | None]] = [] + tool = payload.get("tool_name") + if isinstance(tool, str): + entries += [(reason, "tool", rule_key(reason, "tool"), None) for reason in check_tool(tool, role)] + if command: + entries += [(reason, "command", rule_key(reason), None) for reason in check_command(command, role)] + if path: + target = _safe_target(path, root) + entries += [(reason, "edit", key, target) for key, reason in _edit_denials(path, role, root)] + return entries + + def extract(payload: dict) -> tuple[str | None, str | None]: """从各家载荷中取出 (命令, 文件路径)。""" tool_input = payload.get("tool_input", payload) @@ -214,17 +329,26 @@ def extract(payload: dict) -> tuple[str | None, str | None]: def evaluate(payload: dict, role: str = "designer", root: Path | None = None) -> list[str]: - root = root or Path.cwd() - command, path = extract(payload) - reasons = [] - tool = payload.get("tool_name") - if isinstance(tool, str): - reasons += check_tool(tool, role) - if command: - reasons += check_command(command, role) - if path: - reasons += check_edit(path, role, root) - return reasons + """拒绝理由列表:按工具、命令、编辑顺序聚合,按理由去重(口径与埋点前的判定一致)。""" + return list(dict.fromkeys(entry[0] for entry in denial_entries(payload, role, root))) + + +def _emit_denials(entries: list[tuple[str, str, str, str | None]], role: str) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有规则键、角色、类别与安全相对目标。 + + 理由与命令全文不进事件;emit 失败自行吞掉并提示一次,退出码与 stderr 的拒绝文本不受影响。 + """ + seen: set[tuple[str, str, str | None]] = set() + for _reason, category, key, target in entries: + identity = (category, key, target) + if identity in seen: + continue + seen.add(identity) + outputs = {"category": category} + if target: + outputs["target"] = target + events.emit("guard", "command", "deny", decision={"by": "guard", "rule": key}, + outputs=outputs, actor={"role": role}) def main(argv: list[str] | None = None) -> int: @@ -241,13 +365,14 @@ def main(argv: list[str] | None = None) -> int: except json.JSONDecodeError: print("harness 守卫:无法解析工具调用载荷,按拒绝处理。", file=sys.stderr) return 2 - reasons = evaluate(payload, args.role) - if not reasons: + entries = denial_entries(payload, args.role) + if not entries: return 0 print("harness 守卫拒绝了这次操作:", file=sys.stderr) - for reason in dict.fromkeys(reasons): + for reason in dict.fromkeys(entry[0] for entry in entries): print(f" - {reason}", file=sys.stderr) print(" 如确需执行,请把原因和命令交给用户决定(规则见 .harness/config/rules.toml)。", file=sys.stderr) + _emit_denials(entries, args.role) return 2 diff --git a/.harness/engine/guards/git_guard.py b/.harness/engine/guards/git_guard.py index 63619a8..5aeec42 100644 --- a/.harness/engine/guards/git_guard.py +++ b/.harness/engine/guards/git_guard.py @@ -9,6 +9,8 @@ 对应 v0.8.0 X3:filter-repo 改写了本地 main 与 18 个 tag,并删除了 origin。 覆盖变量见 .harness/config/rules.toml [guard];只供人使用。 +三个钩子的拒绝在报告边界逐条写 guard 事件(钩子名、分支、规则键;理由文本含 ref 与规则说明,不进事件), +放行不记(设计 3.6)。 """ from __future__ import annotations @@ -20,6 +22,7 @@ from pathlib import Path from engine.checks import hygiene +from engine.core import events from engine.core.common import LEGACY_RULES_REL, ROOT, RULES_REL, ZERO_SHA, git, load_rules HOOKS_DIR = ".githooks" @@ -99,11 +102,11 @@ def _packing_loose_ref(ref: str, old: str, cwd: Path) -> bool: return _transaction_subcommand() == "pack-refs" -def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - """reference-transaction 的 prepared 阶段:返回应拒绝的更新。""" +def ref_transaction_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """reference-transaction 的 prepared 阶段:返回应拒绝的 (规则键, 理由, 引用)。""" if _allowed("HARNESS_ALLOW_REWRITE"): return [] - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 3: @@ -116,17 +119,18 @@ def ref_transaction_violations(lines: list[str], cwd: Path, rules: dict) -> list continue # 散文件并入 packed-refs,引用本身不变 if ref.startswith("refs/tags/") and old != ZERO_SHA and old != new: action = "删除" if new == ZERO_SHA else "移动" - problems.append(f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)") + denials.append(("tag_rewrite", f"{action}已有 tag {ref}(已发布的 tag 不能改;推 tag 会触发发布)", ref)) elif _protected(ref, rules) and old != ZERO_SHA and old != new: if new == ZERO_SHA: - problems.append(f"删除保护分支 {ref}") + denials.append(("protected_rewrite", f"删除保护分支 {ref}", ref)) elif not _is_ancestor(old, new, cwd): - problems.append(f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)") - return problems + denials.append(("protected_rewrite", f"改写保护分支 {ref}({old[:7]} → {new[:7]} 不是快进)", ref)) + return denials -def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: - problems = [] +def pre_push_denials(lines: list[str], cwd: Path, rules: dict) -> list[tuple[str, str, str]]: + """pre-push 的引用检查:返回应拒绝的 (规则键, 理由, 远端引用)。""" + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4: @@ -134,18 +138,22 @@ def pre_push_violations(lines: list[str], cwd: Path, rules: dict) -> list[str]: _local_ref, local_sha, remote_ref, remote_sha = parts if remote_ref.startswith("refs/tags/"): if not _allowed("HARNESS_ALLOW_TAG"): - problems.append(f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行") + denials.append(("push_tag", f"推送 tag {remote_ref}:推 tag 会触发发布,只能由用户执行", remote_ref)) continue if _protected(remote_ref, rules) and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR") + denials.append(("push_protected_branch", f"直接推送保护分支 {remote_ref}:请推到功能分支并开 PR", remote_ref)) continue if local_sha == ZERO_SHA or remote_sha == ZERO_SHA or _allowed("HARNESS_ALLOW_REWRITE"): continue # 删除分支、新建分支 if not _exists(remote_sha, cwd): - problems.append(f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交") + denials.append(("push_stale_remote", + f"{remote_ref} 远端有本地没有的提交 {remote_sha[:7]}:先 fetch,确认不会覆盖别人的提交", + remote_ref)) elif not _is_ancestor(remote_sha, local_sha, cwd): - problems.append(f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写") - return problems + denials.append(("force_push", + f"强制推送 {remote_ref}({remote_sha[:7]} → {local_sha[:7]} 不是快进):已推送的历史不改写", + remote_ref)) + return denials def current_branch(cwd: Path) -> str: @@ -175,38 +183,46 @@ def _run_verify(repo: Path, *args: str) -> int: def cmd_pre_commit(repo: Path) -> int: rules = trusted_rules(repo) - problems = [] + denials: list[tuple[str, str, str]] = [] branch = current_branch(repo) if branch in rules["guard"]["protected_branches"] and not _allowed("HARNESS_ALLOW_MAIN"): - problems.append(f"在保护分支 {branch} 上提交:请在功能分支上工作") - problems += [violation.render() for violation in hygiene.scan_staged(repo, rules)] - if _report("提交", problems): + denials.append(("commit_protected_branch", f"在保护分支 {branch} 上提交:请在功能分支上工作", branch)) + denials += [("staged_hygiene", violation.render(), branch) for violation in hygiene.scan_staged(repo, rules)] + code = _report("提交", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-commit", denials) return 1 return _run_verify(repo, "--quick") -def push_hygiene_violations(lines: list[str], repo: Path, rules: dict) -> list[str]: +def push_hygiene_denials(lines: list[str], repo: Path, rules: dict) -> list[tuple[str, str, str]]: """本次推送带出的改动做卫生检查(与 CI 的 --range 同口径,提前在本机发现)。""" - problems = [] + denials: list[tuple[str, str, str]] = [] for line in lines: parts = line.split() if len(parts) != 4 or parts[1] == ZERO_SHA or parts[2].startswith("refs/tags/"): continue - local_sha, remote_sha = parts[1], parts[3] + local_sha, remote_ref, remote_sha = parts[1], parts[2], parts[3] if remote_sha != ZERO_SHA and _exists(remote_sha, repo): base = remote_sha else: base = git("merge-base", local_sha, "origin/main", cwd=repo, check=False) if not base: continue - problems += [violation.render() for violation in hygiene.scan_range(base, local_sha, repo, rules)] - return problems + denials += [("push_hygiene", violation.render(), remote_ref) + for violation in hygiene.scan_range(base, local_sha, repo, rules)] + return denials def cmd_pre_push(repo: Path, stdin: str) -> int: rules = trusted_rules(repo) lines = stdin.splitlines() - if _report("推送", pre_push_violations(lines, repo, rules) or push_hygiene_violations(lines, repo, rules)): + denials = pre_push_denials(lines, repo, rules) + if not denials: + denials = push_hygiene_denials(lines, repo, rules) + code = _report("推送", [denial[1] for denial in denials]) + if code: + _emit_denials("pre-push", denials) return 1 return _run_verify(repo) @@ -214,7 +230,19 @@ def cmd_pre_push(repo: Path, stdin: str) -> int: def cmd_reference_transaction(repo: Path, state: str, stdin: str) -> int: if state != "prepared": return 0 - return _report("引用更新", ref_transaction_violations(stdin.splitlines(), repo, trusted_rules(repo))) + denials = ref_transaction_denials(stdin.splitlines(), repo, trusted_rules(repo)) + code = _report("引用更新", [denial[1] for denial in denials]) + if code: + _emit_denials("reference-transaction", denials) + return code + + +def _emit_denials(hook: str, denials: list[tuple[str, str, str]]) -> None: + """拒绝报告边界逐条记事件(设计 3.6):只有钩子名、分支与规则键,理由文本不进事件; + 事件写入失败由 emit 吞掉并提示一次,钩子的拒绝输出与退出码不变。""" + for key, _message, branch in denials: + events.emit("guard", "git", "deny", decision={"by": "guard", "rule": key}, + outputs={"hook": hook, "branch": branch}) def hooks_path(cwd: Path = ROOT) -> str: diff --git a/.harness/engine/guards/shell_structure.py b/.harness/engine/guards/shell_structure.py new file mode 100644 index 0000000..4b664d3 --- /dev/null +++ b/.harness/engine/guards/shell_structure.py @@ -0,0 +1,29 @@ +"""覆盖变量判定的 shell 结构解析(B60):引号内文字与 heredoc 正文一律当数据。 + +覆盖变量(HARNESS_*、*_ALLOW_MAIN/TAG/REWRITE、_SKIP_VERIFY 及拼接形式如 ${P}_ALLOW_TAG) +只供人使用,Agent 不能自行设置;但只看真正的赋值位置:命令前缀赋值(VAR=… cmd)、 +`env VAR=…`、`export VAR=…`,以及递归展开后同样的位置(`bash -c`、`eval`、`$(...)`、反引号)。 +heredoc(`<<`、`<<-`)与 here-string(`<<<`)正文、引号内文字、普通参数(如 `grep HARNESS_ …`) +一律当数据,不参与覆盖变量判定。 + +结构展开委托给 engine.core.shell_structure(把命令拆成简单命令并递归处理嵌套结构), +这里只取其中覆盖变量的判定结果;无法解析(引号不配对)时保守地整段扫描原文(宁可误报)。 +""" + +from __future__ import annotations + +import re + +from engine.core import shell_structure as structure + +# 出现变量名或其后半截即视为覆盖变量(拼接如 ${P}_ALLOW_TAG 也能命中;评审 PR7-R6)。 +PATTERN = re.compile(r"HARNESS_|_ALLOW_(MAIN|TAG|REWRITE)\b|_SKIP_VERIFY\b") + + +def check(command: str) -> list[str]: + """命令在真赋值位置设置覆盖变量时返回拒绝理由;数据里的文字(heredoc 正文、引号内)不拒绝。""" + try: + found = structure.check(command, lambda _text: [], "designer") + except ValueError: # 引号不配对等无法解析:退回整段扫描(宁可误报) + return [structure.OVERRIDE] if PATTERN.search(command) else [] + return [reason for reason in found if reason == structure.OVERRIDE] diff --git a/.harness/engine/lang/swift.py b/.harness/engine/lang/swift.py index 8c8e465..5f94fda 100644 --- a/.harness/engine/lang/swift.py +++ b/.harness/engine/lang/swift.py @@ -1,9 +1,11 @@ -"""Swift 语言插件(只用标准库的轻量解析)。 +r"""Swift 语言插件(只用标准库的轻量解析)。 函数体量与嵌套深度:先把注释(含嵌套块注释)与字符串字面量(普通、多行三引号、带 # 的原始字符串)抹成空格, -再配对大括号。「函数」指 func、init、deinit 与计算属性(`var x: T {`,SwiftUI 的 body 即此类);函数体行数从 -`{` 所在行数到匹配的 `}` 所在行,嵌套深度以函数体自身为 1,闭包与控制流都计入。解析不求完美(如 #if 分支、 -正则字面量不处理),只需对同一份代码稳定。签名按声明文本比对,重载按多个签名计;新增 import 即算新依赖。 +再配对大括号。字符串插值 `\(...)` 里的代码连同内嵌的字符串字面量一起抹平,不让内层引号提前终止外层字符串。 +「函数」指 func、init、deinit 与计算属性(`var x: T {`,SwiftUI 的 body 即此类);协议成员只有声明要求、 +没有实现体(如 `var x: T { get }`),不计入。函数体行数从 `{` 所在行数到匹配的 `}` 所在行,嵌套深度以函数体 +自身为 1,闭包与控制流都计入。解析不求完美(如 #if 分支、正则字面量不处理),只需对同一份代码稳定。 +签名按声明文本比对,重载按多个签名计;新增 import 即算新依赖。 """ from __future__ import annotations @@ -23,6 +25,67 @@ r"(?[^\s(<]+)|(?Pinit|deinit)\b[?!]?|var\s+(?P\w+)\s*:\s*[^=\n{};]+?\{)" ) _DECL_KEYWORD = re.compile(r"(? int: + r"""从 opening 之后扫描字符串字面量,返回结束位置(closing 之后)。 + + 识别插值 `\(...)` / `\#(...)`:插值内的嵌套字符串递归配对,不让内层引号提前终止外层字符串。 + 单行字符串未闭合时吞到行尾或文件尾(与既有行为一致)。 + """ + closing = quote + hashes + multiline = quote == '"""' + n = len(source) + j = start + while j < n: + ch = source[j] + if ch == "\\": + if source.startswith(hashes + "(", j + 1): + j = _interp_end(source, j + 2 + len(hashes), hashes, multiline) + continue + if not hashes: # 普通字符串的转义 `\x`;原始字符串没有转义 + j += 2 + continue + if source.startswith(closing, j): + return j + len(closing) + if ch == "\n" and not multiline: + return j + j += 1 + return n + + +def _interp_end(source: str, start: int, hashes: str, multiline: bool) -> int: + r"""扫描插值表达式,返回配对 `)` 之后的位置;插值内是代码上下文。 + + 圆括号计深度;字符串字面量(含插值,可递归)整体配对后跳过;单行字符串的插值遇到 + 换行视为未闭合,停在该换行处。 + """ + n = len(source) + depth = 1 + j = start + while j < n: + ch = source[j] + if ch == '"': + match = _STRING_START.match(source, j) + if match: + j = _string_end(source, match.end(), match.group(1), match.group(2)) + continue + elif ch == "#": + match = _STRING_START.match(source, j) + if match and match.group(1): # 插值里的原始字符串 `#"…"#` + j = _string_end(source, match.end(), match.group(1), match.group(2)) + continue + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return j + 1 + elif ch == "\n" and not multiline: + return j + j += 1 + return n def strip(source: str) -> str: @@ -54,19 +117,7 @@ def strip(source: str) -> str: continue match = _STRING_START.match(source, i) if match and (match.group(1) or ch == '"'): - hashes, quote = match.group(1), match.group(2) - closing = quote + hashes - j = match.end() - while j < n: - if not hashes and source[j] == "\\": - j += 2 - continue - if source.startswith(closing, j): - j += len(closing) - break - if quote == '"' and source[j] == "\n": - break - j += 1 + j = _string_end(source, match.end(), match.group(1), match.group(2)) out.append("".join("\n" if c == "\n" else " " for c in source[i:j])) i = j continue @@ -94,7 +145,7 @@ def _body_open(text: str, index: int) -> int | None: def functions(source: str) -> list[tuple[str, int, int, int]]: - """返回 (名字, 声明行号, 函数体行数, 最大嵌套深度)。""" + """返回 (名字, 声明行号, 函数体行数, 最大嵌套深度)。协议成员只有要求、没有实现体,不计入。""" text = strip(source) closing: dict[int, int] = {} stack: list[int] = [] @@ -103,11 +154,18 @@ def functions(source: str) -> list[tuple[str, int, int, int]]: stack.append(index) elif ch == "}" and stack: closing[stack.pop()] = index + protocols: list[tuple[int, int]] = [] + for match in _PROTOCOL.finditer(text): + open_at = _body_open(text, match.end()) + if open_at is not None and open_at in closing: + protocols.append((open_at, closing[open_at])) functions = [] for decl in _SWIFT_DECL.finditer(text): open_at = decl.end() - 1 if decl.group("var") else _body_open(text, decl.end()) if open_at is None or open_at not in closing: continue + if any(lo <= open_at <= hi for lo, hi in protocols): + continue close_at = closing[open_at] depth = deepest = 0 for ch in text[open_at : close_at + 1]: diff --git a/.harness/engine/prompts/dispatch_prompt.md b/.harness/engine/prompts/dispatch_prompt.md index cef6cba..af8e8fc 100644 --- a/.harness/engine/prompts/dispatch_prompt.md +++ b/.harness/engine/prompts/dispatch_prompt.md @@ -26,4 +26,18 @@ - 需要设计方或用户决定的具体问题: 派发脚本会补上当前状态、已尝试的方案与证据,并交给设计方。 + +## 完成时的缺失上下文报告 + +结束前回顾:是否有缺失的上下文、工具或规格影响了你的工作。在最后一条回复的末尾输出一行机器可读报告: +固定尾标记 `HARNESS_CONTEXT_JSON:` 后跟一行 JSON 数组(它是数据标记,不是 shell 变量,不要用命令构造或求值它)。 + +- 没有缺失:显式输出 `HARNESS_CONTEXT_JSON: []`。 +- 每个条目只有 `category`、`summary`、`ref` 三个字段。`category` 只能取 `context` / `tool` / `spec` / `other`; + `summary` 只能用与类别对应的短 ID:`context_unavailable`(context)、`required_tool_unavailable`(tool)、 + `spec_unavailable` 与 `spec_ambiguous`(spec)、`other_missing`(other);`ref` 可选,只写工具 ID + (如 `python`)或仓库相对引用(如 `docs/plans/x.md`),不写绝对路径、命令或句子。 +- 例:`HARNESS_CONTEXT_JSON: [{{"category":"tool","summary":"required_tool_unavailable","ref":"gh"}}]` +- 更详细的说明照常写在回复正文;引擎只从上面的标记行采集,正文与长文不入运行记录。 + {feedback} diff --git a/.harness/engine/prompts/review_prompt.md b/.harness/engine/prompts/review_prompt.md index d1acfae..8bf3f8a 100644 --- a/.harness/engine/prompts/review_prompt.md +++ b/.harness/engine/prompts/review_prompt.md @@ -4,7 +4,7 @@ - `build/review/pr.md`:PR 标题与描述(作者的自述,不能当作证据) - `build/review/task.md`:任务书(没有任务书时写明「无」) -- `build/review/diff.patch`:相对合并基点的完整改动 +- `build/review/diff.patch`:相对合并基点的完整改动;超 2MB 时按文件分片为 `diff-01.patch`、`diff-02.patch`…(全部分片,无截断),以 `pack.md` 顶部的材料清单为准 - `build/review/pack.md`:机器生成的证据包(风险等级、修复证据的提交与测试映射、涉及的验收编号) - `build/review/ci.md`:当前 head 的 CI 检查结论与链接(核对 PR 描述中「CI 通过」一类说法以它为准) - 材料为空或与 PR 描述明显不符时,结论为「需用户验收」并在发现里写明,不要在空材料上给出通过 diff --git a/.harness/engine/reports/audit.py b/.harness/engine/reports/audit.py new file mode 100644 index 0000000..ca52838 --- /dev/null +++ b/.harness/engine/reports/audit.py @@ -0,0 +1,762 @@ +"""审计引用复原与哈希核对(B46 T401,设计 4.2,共用合同 C7)。 + + python3 cli.py audit [--json] + python3 cli.py audit --all-merged [--since 30d] [--json] + +复原一个(或 --all-merged 批量分页确定性时间窗口内的全部)已合并 PR 的安全环节视图:按 API 的 +trace/head、C6 合并账本与本机/CI 已导入事件列出各阶段的输入、输出与决定,来源分别标注 +local/ci/github。下载 CI 由 C5/T302 共享的 load_ci 完成,GitHub 事实更新由 T304 共享的 sync 完成, +本命令不写合并路由;cli.py QUIET_COMMANDS 不记 cli.audit 入口事件,每次运行结束由本模块写一条 +ci/audit.summary 与每条 finding 一条 ci/audit.finding 观察事件(T404,C7 供周报,失败隔离见 +_emit_observations)。 + +逐引用核对四类:git 文件按 path@rev 用 git show 取原始字节(结尾换行原样参与哈希,不去尾、不再 +规范化);GitHub 评论按不可变正文原字节、可变 PR 字段按评审材料同一渲染规则复取(变动报 +snapshot_changed,不与固定绑定提交的事实混比);本机产物按 sha256/size 并区分保留期到期;CI 事件 +包的规范化哈希核对由 load_ci 导入完成,发现按来源映射进报告。只访问当前仓库受支持的 GitHub 资源 +(评论、PR 字段、Actions 包),引用内容只解析与比对、绝不执行;path 不能逃出仓库。失败区分 +hash_mismatch / reference_unavailable / reference_expired,未核对在 coverage 里如实计数,不冒充通过。 +完整性规则、锚点防篡改与 [audit] 配置由 audit_completeness 在复原核对之后判定(B46 T402)。 + +inspect_pr(pr, *, gh=None, cwd=ROOT) 返回 C7 基础形状:pr/trace_id/head_sha/stages/references/ +findings/coverage/ok;finding 有 rule/severity/source/stage/ref/reason,reason 不回显私密内容。 +退出码:0 全部适用引用核对通过;1 有发现(哈希不符、引用不可得、到期或完整性/防篡改发现); +2 参数错误、整体 API 故障、[audit] 配置错误或 PR 无法审计。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sqlite3 +import subprocess +import sys +from collections import Counter +from contextlib import closing +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from engine.core import events, events_db, events_io +from engine.core.common import ROOT, clean_git_env +from engine.reports import audit_completeness, github_events, ledger + +# 批量模式 --since 的相对时长单位(折算为秒)。 +_DURATION_RE = re.compile(r"(\d+)([dhms])\Z") +_UNITS = {"d": 86400, "h": 3600, "m": 60, "s": 1} + +# C7 稳定 rule:T401 产生引用核对四类;完整性/锚点/账本一致性等其余稳定 rule 由 T402 扩展。 +_RULE_FOR_STATUS = { + "hash_mismatch": "hash_mismatch", + "snapshot_changed": "snapshot_changed", + "unavailable": "reference_unavailable", + "expired": "reference_expired", +} +_STATUSES = ("verified", "hash_mismatch", "snapshot_changed", "unavailable", "expired", "unchecked") +_SOURCE_CLASSES = ("local", "ci", "github") +_HASH_RE = re.compile(r"[0-9a-f]{64}\Z") +_SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +_GIT_REF_RE = re.compile(r"(?P.+)@(?P[0-9a-f]{7,40})\Z") +# 只接受 owner/repo#PR/comments/ID 形状的评论引用;其余(任意 URL、其他仓库)一律拒绝,不发起请求。 +_COMMENT_REF_RE = re.compile(r"(?P[A-Za-z0-9_.-]+)/(?P[A-Za-z0-9_.-]+)" + r"#(?P\d+)/comments/(?P\d+)\Z") +# PR 锚点评论(T305 ledger._anchor_body 的固定行)里记录的账本路径、提交与文件 sha256。 +_ANCHOR_FILE_RE = re.compile(r"- 文件:`(?P[^`]+)` @ (?P[0-9a-f]{40})") +_ANCHOR_SHA_RE = re.compile(r"- 文件 sha256:`(?P[0-9a-f]{64})`") + + +class AuditError(RuntimeError): + """PR 无法审计(整体 API 故障、PR 不存在或未合并);CLI 转退出码 2。""" + + +def _parse_window(text: str) -> datetime: + """批量模式 --since 的窗口起点: 相对时长或 ISO 时间戳,坏值抛 ValueError。 + + 相对时长按审计自身的时间基准(events_db._now)折算而不取墙钟:批量范围与事件/锚点同一时钟, + 因此是确定性时间范围(同一时钟下两次运行覆盖同一集合)。 + """ + if match := _DURATION_RE.fullmatch(text.strip()): + now = _parse_utc(events_db._now()) + if now is None: + raise ValueError("--since 无法确定审计时间基准") + return now - timedelta(seconds=int(match[1]) * _UNITS[match[2]]) + parsed = _parse_utc(text) + if parsed is None: + raise ValueError(f"--since 无法解析为时长或 UTC 时间:{text}") + return parsed + + +# 缺省 gh 客户端:repo/pr/api/download 全都具备(load_ci 与 sync 只用这些);测试可注入替换。 +GhClient = ledger.GhClient + + +def _finding(rule: str, *, source: str, stage: str, ref: str, reason: str, + severity: str = "error") -> dict: + """C7 finding:rule/severity/source/stage/ref/reason;reason 只写分类,不回显引用内容。""" + return {"rule": rule, "severity": severity, "source": source, "stage": stage, + "ref": ref, "reason": reason} + + +def _digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _parse_utc(value) -> datetime | None: + """UTC ISO 时间戳 → aware UTC;缺失或损坏返回 None(调用方按无法判定处理)。""" + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.strip()) + except ValueError: + return None + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC) + + +def _source_class(source: str) -> str: + """来源显示分类(C2/C7):github:/ci 前缀各成一类,其余(含 local)归本机。""" + if source.startswith("github:"): + return "github" + if source.startswith("ci"): + return "ci" + return "local" + + +def _safe_repo_path(path: str) -> bool: + """仓库相对路径合法性:拒绝绝对路径、盘符、反斜杠、~ 与 . / .. 段(path 不能逃出仓库)。""" + if not path or path.startswith(("/", "~")) or ":" in path or "\\" in path or "\0" in path: + return False + return not any(part in ("", ".", "..") for part in path.split("/")) + + +def _git_run(cwd: Path, *args: str) -> tuple[bool, str]: + """行输出 git 调用(ls-remote/fetch/ls-tree):返回(成功, stdout 去尾换行)。""" + done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, env=clean_git_env(), check=False) + return done.returncode == 0, done.stdout.decode("utf-8", "replace").rstrip("\n") + + +def _git_bytes(cwd: Path, rev: str, path: str) -> bytes | None: + """git show : 的原始字节(结尾换行原样保留,不做任何规范化);失败返回 None。""" + done = subprocess.run(["git", "show", f"{rev}:{path}"], cwd=cwd, capture_output=True, + env=clean_git_env(), check=False) + return done.stdout if done.returncode == 0 else None + + +def _artifact_created(digest: str) -> str | None: + """本机产物索引里的 created(只读查询;行不存在或读取失败返回 None)。""" + path = events_db.db_path() + if path is None or not path.exists(): + return None + try: + with closing(sqlite3.connect(path, timeout=events_db.BUSY_TIMEOUT_MS / 1000)) as conn: + row = conn.execute("SELECT created FROM artifacts WHERE sha256=?", (digest,)).fetchone() + except sqlite3.Error: + return None + return row[0] if row else None + + +class _Auditor: + """一个已合并 PR 的复原与逐引用核对:收集 → 合并账本期望 → 逐条核对 → 汇总报告。""" + + def __init__(self, *, client, repo: str, pr: int, trace: str, head: str, merge_sha: str, + merged_at: str, cwd: Path): + self.client, self.repo, self.pr = client, repo, pr + self.trace, self.head, self.merge_sha, self.merged_at = trace, head, merge_sha, merged_at + self.cwd = Path(cwd) + now = _parse_utc(events_db._now()) or datetime.now(UTC) + # 窗口与到期判定的时间基准一次取定(测试冻结时钟);到期阈值沿用实际生效的产物保留期。 + self.cutoff = now - timedelta(days=events.artifact_days()) + self.findings: list[dict] = [] + self.refs: list[dict] = [] + self.stages: list[dict] = [] + self.anchors: list[dict] = [] + self.chains: list[dict] = [] + self.event_rows: list[dict] = [] + self.ledger_doc = None # 解析后的账本(T402 账本一致性核对用;None=无账本/不可解析) + self.db_error = None # 事件库不可读标记(T402:坏库如实报告,不当作无事件) + self.ledger_state = "absent" + self._expect: dict[tuple, dict] = {} # (kind, ref) → 账本记录的期望 sha256/size + + # ---- 收集 ---- + + def _add(self, *, check: str, kind: str, ref, sha256, size, source: str, stage: str, + expectation: str, event_ts: str | None = None, artifact: str | None = None, + note: str | None = None, ledger_bytes: bytes | None = None) -> dict: + entry = {"check": check, "kind": kind, "ref": ref, "sha256": sha256, "size": size, + "source": source, "stage": stage, "expectation": expectation, + "event_ts": event_ts, "artifact": artifact, "note": note, + "ledger_bytes": ledger_bytes, "status": "unchecked", "reason": note, + "observed_sha256": None, "observed_size": None} + self.refs.append(entry) + return entry + + def _collect_events(self): + """本机/CI/GitHub 已导入事件 → 阶段视图、链头与锚点;local 事件的引用逐条登记核对。""" + try: + rows = events_io.query(trace_id=self.trace) + anchors = events_db.read_anchors(trace_id=self.trace) + except Exception: # noqa: BLE001 坏库:如实交完整性层报告(T402),不崩溃、不当作无事件 + self.db_error = True + rows, anchors = [], [] + self.event_rows = sorted(rows, + key=lambda row: (row["ts"], row["source"], row["seq"])) + for row in self.event_rows: + self.stages.append({"evidence": "event", "source": row["source"], + "source_class": _source_class(row["source"]), "stage": row["stage"], + "step": row["step"], "status": row["status"], "ts": row["ts"], + "duration_ms": row["duration_ms"], "seq": row["seq"], "hash": row["hash"], + "inputs": row["inputs"], "outputs": row["outputs"], + "decision": row["decision"], "error": row["error"], "actor": row["actor"]}) + heads: dict[str, tuple[int, str]] = {} + for row in self.event_rows: + if row["source"] not in heads or row["seq"] > heads[row["source"]][0]: + heads[row["source"]] = (row["seq"], row["hash"]) + self.chains = [{"source": source, "trace_id": self.trace, "head_hash": heads[source][1]} + for source in sorted(heads)] + self.anchors.extend(anchors) + for row in self.event_rows: + if _source_class(row["source"]) != "local": + continue # CI 事件的产物在 CI 侧(C5 导入不恢复内容),到期由 load_ci 发现报告 + self._collect_input_refs(row) + self._collect_output_refs(row) + + def _collect_input_refs(self, row: dict): + """local 事件输入引用:artifact 引用与 path@rev 形状的 git 文件引用(须带记录的 sha256)。""" + for item in row["inputs"]: + if not isinstance(item, dict) or not isinstance(item.get("sha256"), str): + continue + if item.get("kind") == "artifact": + ref_value = item.get("ref") + self._add(check="artifact", kind="artifact", ref=ref_value or item["sha256"], + sha256=item["sha256"], size=item.get("size"), source="local", + stage=row["stage"], expectation="event", event_ts=row["ts"], + artifact=ref_value if isinstance(ref_value, str) + and _HASH_RE.fullmatch(ref_value) is not None else item["sha256"]) + elif _GIT_REF_RE.fullmatch(str(item.get("ref") or "")) is not None: + self._add(check="git", kind=str(item.get("kind") or "git"), ref=item["ref"], + sha256=item["sha256"], size=item.get("size"), source="local", + stage=row["stage"], expectation="event") + + def _collect_output_refs(self, row: dict): + """输出引用从扁平 outputs 三元字段(.sha256/.size/.ref)派生(C1),核对本机产物。""" + outputs = row["outputs"] if isinstance(row["outputs"], dict) else {} + prefixes = sorted({key.rsplit(".", 1)[0] for key in outputs if key.endswith(".sha256")}) + for prefix in prefixes: + recorded = outputs.get(f"{prefix}.sha256") + if not isinstance(recorded, str) or _HASH_RE.fullmatch(recorded) is None: + continue + ref_value = outputs.get(f"{prefix}.ref") + size_value = outputs.get(f"{prefix}.size") + artifact = ref_value if isinstance(ref_value, str) and _HASH_RE.fullmatch(ref_value) else recorded + self._add(check="artifact", kind="artifact", + ref=ref_value if isinstance(ref_value, str) else artifact, sha256=recorded, + size=size_value if isinstance(size_value, int) and not isinstance(size_value, bool) + else None, + source="local", stage=row["stage"], expectation="event", event_ts=row["ts"], + artifact=artifact) + + def _collect_comments(self): + """PR 评论:评审审计摘要(C6 标记)→ 评审阶段视图与材料核对;锚点评论 → 账本期望。""" + problems: list[dict] = [] + comments = github_events._collect_pages( + self.client, f"repos/{self.repo}/issues/{self.pr}/comments", "PR 评论", problems) + if comments is None: + self.findings.append(_finding( + "reference_unavailable", source="github", stage="review", + ref=f"{self.repo}#{self.pr}/comments", + reason=f"PR 评论列表无法读取({problems[0]['code'] if problems else 'api'})")) + return None + anchor = None + marker = f"" + for comment in comments: + body = comment.get("body") if isinstance(comment, dict) else None + if not isinstance(body, str): + continue + if marker in body: + file_match, sha_match = _ANCHOR_FILE_RE.search(body), _ANCHOR_SHA_RE.search(body) + if file_match and sha_match and anchor is None: + anchor = {"path": file_match["path"], "commit": file_match["commit"], + "sha256": sha_match["sha256"], "comment_id": comment.get("id")} + continue + parsed, _problem = ledger._parse_review_audit(body, self.head) + if parsed is None: + continue # 解析失败或非本 head 的评审摘要不作为证据(缺评审的完整性判断属 T402) + self.stages.append({ + "evidence": "review_comment_summary", "source": "github", "source_class": "github", + "stage": "review", "step": "review", "ts": comment.get("created_at"), "trace_id": parsed.get("trace_id"), + "head": parsed.get("head"), "reviewer": parsed.get("reviewer"), + "model": parsed.get("model"), "designer": parsed.get("designer"), + "implementers": parsed.get("implementers"), "independent": parsed.get("independent"), + "verdict": parsed.get("verdict"), "duration_ms": parsed.get("duration_ms"), + "severity_counts": parsed.get("severity_counts"), + "materials": parsed.get("materials"), + "comment": {"id": comment.get("id"), "url": comment.get("html_url")}}) + self._add(check="comment", kind="review_comment", + ref=f"{self.repo}#{self.pr}/comments/{comment.get('id')}", + sha256=None, size=len(body.encode("utf-8")), source="github", stage="review", + expectation=None) + self._collect_materials(parsed) + return anchor + + def _collect_materials(self, parsed: dict): + """评审审计材料:task@head 与 pack 产物按原字节核对;pr 材料按同一渲染规则复取比对; + diff/ci 是评审时按 recipe 渲染的字节(C6:不按将来复取对象重算),登记为不核对。""" + for material in parsed.get("materials") or []: + if not isinstance(material, dict): + continue + kind, ref = material.get("kind"), material.get("ref") + sha256, size = material.get("sha256"), material.get("size") + recipe = material.get("recipe") if isinstance(material.get("recipe"), dict) else {} + if kind == "task" and ref == "none": + continue # 无任务书的「无」哨兵:没有内容可核对 + if kind == "task": + self._add(check="git", kind="material_task", ref=ref, sha256=sha256, size=size, + source="local", stage="review", expectation="review_material") + elif kind == "pack" and isinstance(sha256, str): + digest = recipe.get("artifact_sha256") if isinstance(recipe.get("artifact_sha256"), str) else sha256 + self._add(check="artifact", kind="material_pack", ref=ref if isinstance(ref, str) else digest, + sha256=sha256, size=size, source="local", stage="review", + expectation="review_material", artifact=digest) + elif kind == "pr" and ref == f"pull/{self.pr}" and isinstance(sha256, str): + self._add(check="snapshot", kind="material_pr", ref=ref, sha256=sha256, size=size, + source="github", stage="review", expectation="review_material") + else: + self._add(check="skip", kind=f"material_{kind}", ref=ref, sha256=sha256, size=size, + source="github", stage="review", expectation="review_material", + note="评审时按 recipe 渲染,复取不保证同字节,不核对") + + def _collect_run_records(self): + """合并 head 上的运行记录(路径@merge_sha 原始字节):摘要入阶段视图,记录本身登记核对。""" + ok, listing = _git_run(self.cwd, "ls-tree", "-r", "--name-only", self.merge_sha, "--", "docs/runs") + if not ok: + return + for path in [line.strip() for line in listing.splitlines() if line.strip().endswith(".json")]: + data = _git_bytes(self.cwd, self.merge_sha, path) + if data is None: + self.findings.append(_finding( + "reference_unavailable", source="local", stage="dispatch", + ref=f"{path}@{self.merge_sha}", reason="运行记录无法从合并 head 读取")) + continue + try: + record = json.loads(data) + except ValueError: + continue + if not isinstance(record, dict) or self.trace not in (record.get("trace_id"), record.get("branch")): + continue + for item in record.get("stages") or []: + if isinstance(item, dict): + self.stages.append({"evidence": "run_record_summary", "source": "local", + "source_class": "local", "record": f"{path}@{self.merge_sha}", **item}) + for anchor in record.get("anchors") or []: + if isinstance(anchor, dict): + self.anchors.append({"trace_id": self.trace, **anchor}) + entry = self._add(check="git", kind="run_record", ref=f"{path}@{self.merge_sha}", + sha256=None, size=None, source="local", stage="dispatch", expectation=None) + entry["observed_sha256"], entry["observed_size"] = _digest(data), len(data) + + def _collect_ledger(self, anchor): + """C6 账本:锚点评论记录账本字节 sha256;账本 references 是运行记录/评审评论的核对期望。""" + if anchor is None: + return + ref = f"{ledger.AUDIT_BRANCH}:{anchor['path']}@{anchor['commit']}" + ok, _ = _git_run(self.cwd, "ls-remote", "origin", f"refs/heads/{ledger.AUDIT_BRANCH}") + fetched = False + data = None + if ok: + ok, _ = _git_run(self.cwd, "fetch", "--quiet", "origin", ledger.AUDIT_BRANCH) + if ok: + data = _git_bytes(self.cwd, anchor["commit"], anchor["path"]) + fetched = data is not None + entry = self._add(check="ledger", kind="ledger", ref=ref, sha256=anchor["sha256"], size=None, + source="github", stage="merge", expectation="anchor_comment", ledger_bytes=data) + if not fetched: + entry["status"] = "unavailable" + entry["reason"] = "账本文件无法从 harness-audit 分支读取" + return + try: + parsed = json.loads(data) + except ValueError: + parsed = None + self.ledger_doc = parsed if isinstance(parsed, dict) else None + if not isinstance(parsed, dict): + entry["status"] = "hash_mismatch" + entry["reason"] = "账本文件不是 JSON 对象" + entry["observed_sha256"], entry["observed_size"] = _digest(data), len(data) + return + entry["observed_sha256"], entry["observed_size"] = _digest(data), len(data) + for item in parsed.get("references") or []: + if not isinstance(item, dict) or not isinstance(item.get("ref"), str) \ + or not isinstance(item.get("sha256"), str): + continue + self._expect[(item.get("kind"), item["ref"])] = {"sha256": item["sha256"], + "size": item.get("size")} + + def _apply_expectations(self): + """账本记录的期望补到已收集的引用上;账本里有、视图里没收集到的引用按账本记录补收核对。""" + for entry in self.refs: + hit = self._expect.pop((entry["kind"], entry["ref"]), None) + if hit and entry["sha256"] is None: + entry["sha256"] = hit["sha256"] + entry["size"] = entry["size"] if entry["size"] is not None else hit.get("size") + entry["expectation"] = "ledger" + for (kind, ref), hit in self._expect.items(): + source = "github" if kind == "review_comment" else "local" + check = "comment" if kind == "review_comment" else "git" + self._add(check=check, kind=str(kind), ref=ref, sha256=hit["sha256"], size=hit.get("size"), + source=source, stage="review" if kind == "review_comment" else "dispatch", + expectation="ledger") + + # ---- 核对 ---- + + def _compare(self, entry: dict, data: bytes) -> None: + entry["observed_sha256"], entry["observed_size"] = _digest(data), len(data) + if entry["sha256"] is not None and entry["observed_sha256"] != entry["sha256"]: + entry.update(status="hash_mismatch", reason="内容 sha256 与记录不符(原始字节比对)") + elif entry["size"] is not None and entry["observed_size"] != entry["size"]: + entry.update(status="hash_mismatch", reason="内容大小与记录不符") + else: + entry.update(status="verified", reason=None) + + def _verify(self, entry: dict) -> None: + if entry["check"] == "skip": + return # 保持 unchecked 与不核对的理由 + if not isinstance(entry["sha256"], str) or _HASH_RE.fullmatch(entry["sha256"]) is None: + entry["reason"] = "没有记录的 sha256,无从核对" + return + checker = {"git": self._check_git, "comment": self._check_comment, + "artifact": self._check_artifact, "snapshot": self._check_snapshot, + "ledger": self._check_ledger}[entry["check"]] + checker(entry) + + def _check_git(self, entry: dict) -> None: + match = _GIT_REF_RE.fullmatch(str(entry["ref"])) + if match is None or not _safe_repo_path(match["path"]): + entry.update(status="unavailable", reason="path 不是仓库相对引用或逃出仓库,已拒绝核对") + return + data = _git_bytes(self.cwd, match["rev"], match["path"]) + if data is None: + entry.update(status="unavailable", reason=f"{match['path']}@{match['rev']} 无法从 git 读取") + return + self._compare(entry, data) + + def _check_comment(self, entry: dict) -> None: + match = _COMMENT_REF_RE.fullmatch(str(entry["ref"])) + if match is None: + entry.update(status="unavailable", reason="评论引用形状不符,已拒绝核对") + return + if f"{match['owner']}/{match['name']}" != self.repo or int(match["pr"]) != self.pr: + entry.update(status="unavailable", reason="评论引用不属于当前仓库或本 PR,已拒绝核对") + return + try: + comment = self.client.api(f"repos/{self.repo}/issues/comments/{match['id']}") + except (RuntimeError, ValueError, AttributeError): + entry.update(status="unavailable", reason="评论无法从 API 读取") + return + body = comment.get("body") if isinstance(comment, dict) else None + if not isinstance(body, str): + entry.update(status="unavailable", reason="评论响应形状不符") + return + self._compare(entry, body.encode("utf-8")) + + def _check_artifact(self, entry: dict) -> None: + digest = entry["artifact"] or entry["sha256"] + if not isinstance(digest, str) or _HASH_RE.fullmatch(digest) is None: + entry.update(status="unavailable", reason="产物引用不是 sha256,已拒绝核对") + return + directory = events_db.artifacts_dir() + data = None + if directory is not None: + try: + data = (directory / digest).read_bytes() + except OSError: + data = None + if data is not None: + self._compare(entry, data) + return + # 到期判定:产物索引行还在看 created,行已被保留期清理则回退引用事件的 ts。 + moment = _parse_utc(_artifact_created(digest)) or _parse_utc(entry.get("event_ts")) + if moment is not None and moment < self.cutoff: + entry.update(status="expired", reason="本机产物已过保留期清理,无法核对") + else: + entry.update(status="unavailable", reason="本机产物缺失,无法核对") + + def _check_snapshot(self, entry: dict) -> None: + try: + pull = self.client.api(f"repos/{self.repo}/pulls/{self.pr}") + except (RuntimeError, ValueError, AttributeError): + entry.update(status="unavailable", reason="PR 字段无法从 API 读取") + return + rendered = f"# {pull.get('title')}\n\n{pull.get('body') or ''}".encode() \ + if isinstance(pull, dict) else b"" + # 可变 PR 字段按评审同一渲染规则复取:变动是快照漂移(C6 snapshot_changed),不冒充字节篡改。 + if _digest(rendered) != entry["sha256"] or len(rendered) != entry["size"]: + entry.update(status="snapshot_changed", reason="PR 标题/正文与评审材料快照不符(可变字段已变动)") + else: + entry.update(status="verified", reason=None) + + def _check_ledger(self, entry: dict) -> None: + data = entry.get("ledger_bytes") + if data is None: + entry.update(status="unavailable", reason="账本文件无法从 harness-audit 分支读取") + return + self._compare(entry, data) + + # ---- 共享能力与汇总 ---- + + def _import_ci(self): + """CI 事件包交给 C5/T302 load_ci:下载、来源核对与规范化哈希导入都在那里完成。""" + try: + result = events_io.load_ci(self.pr, head=self.head, gh=self.client) + except Exception as exc: # noqa: BLE001 下载适配故障转发现,不替代审计 + result = {"findings": [{"code": "api", "detail": str(exc)}]} + for item in result.get("findings") or []: + code = item.get("code") + if code == "artifact_expired": + rule, reason = "reference_expired", "CI 事件包 artifact 已过保留期,未下载" + elif code == "hash_mismatch": + rule, reason = "hash_mismatch", "CI 事件包内容与规范化哈希不符,导入被拒" + else: + rule, reason = "reference_unavailable", f"CI 事件包不可用({code})" + self.findings.append(_finding(rule, source="ci", stage="ci", + ref=f"{self.repo}#{self.pr}/actions/artifacts", reason=reason)) + + def _sync_github(self): + """GitHub 事实由 T304 共享入口同步(合并/抽审/逃逸快照幂等导入),审计不自行改写。""" + try: + result = github_events.sync(pr=self.pr, gh=self.client) + except Exception as exc: # noqa: BLE001 同步故障转发现,不替代审计 + result = {"findings": [{"code": "api", "detail": str(exc)}]} + for item in result.get("findings") or []: + self.findings.append(_finding( + "reference_unavailable", source="github", stage="merge", ref=f"{self.repo}#{self.pr}", + reason=f"GitHub 事实同步不完整({item.get('code')})")) + + def run(self) -> dict: + self._import_ci() + self._sync_github() + self._collect_events() + anchor = self._collect_comments() + self._collect_run_records() + self._collect_ledger(anchor) + self._apply_expectations() + for entry in self.refs: + self._verify(entry) + for entry in self.refs: + rule = _RULE_FOR_STATUS.get(entry["status"]) + if rule is not None: + self.findings.append(_finding(rule, source=entry["source"], stage=entry["stage"], + ref=str(entry["ref"]), reason=entry["reason"])) + self.findings.extend(audit_completeness.check(self)) # T402:完整性与防篡改(含 [audit] 配置校验) + self.findings.sort(key=lambda item: (item["rule"], item["source"], item["stage"], item["ref"])) + self.refs.sort(key=lambda item: (item["kind"], str(item["ref"]))) + for entry in self.refs: + entry.pop("ledger_bytes", None) # 内部核对输入(字节)不进报告,报告只留哈希与结论 + self.stages.sort(key=lambda item: (str(item.get("ts")), str(item.get("stage")), str(item.get("step")))) + self.anchors.sort(key=lambda item: (str(item.get("ts")), str(item.get("source")))) + entry = next((item for item in self.refs if item["kind"] == "ledger"), None) + self.ledger_state = entry["status"] if entry is not None else "absent" + classes = Counter(_source_class(row["source"]) for row in self.event_rows) + statuses = Counter(item["status"] for item in self.refs) + coverage = {"events": {name: classes.get(name, 0) for name in _SOURCE_CLASSES}, + "chains": len(self.chains), "anchors": len(self.anchors), + "references": {"total": len(self.refs), + **{name: statuses.get(name, 0) for name in _STATUSES}}, + "ledger": self.ledger_state} + return {"pr": self.pr, "repository": self.repo, "trace_id": self.trace, "head_sha": self.head, + "merge_sha": self.merge_sha, "merged_at": self.merged_at, "stages": self.stages, + "references": self.refs, "findings": self.findings, "chains": self.chains, + "anchors": self.anchors, "coverage": coverage, "ok": not self.findings} + + +def _exit_code(report: dict) -> int: + """0 全部通过;1 有发现;2 配置错误(configuration_error 优先,非法 [audit] 不静默放宽)。""" + if any(item.get("rule") == "configuration_error" for item in report["findings"]): + return 2 + return 1 if report["findings"] else 0 + + +def _flatten_outputs(prefix: str, value, out: dict) -> None: + """嵌套汇总(coverage、规则/severity 计数)摊平成 outputs 扁平键:emit 过滤器只收标量值。""" + if isinstance(value, dict): + for key, item in value.items(): + _flatten_outputs(f"{prefix}.{key}" if prefix else str(key), item, out) + else: + out[prefix] = value + + +def _emit_observations(report: dict) -> None: + """audit 观察事件(C1:audit 自己记汇总;C7:写安全 ci/audit.summary 与 audit.finding 供周报)。 + + 每次运行恰一条 ci/audit.summary(ok/fail 随报告结论),每条 finding 追加一条 ci/audit.finding; + stage 固定 ci,不新增阶段枚举。outputs 只放安全字段(pr/head_sha/规则与 severity 计数/coverage/ok, + finding 事件另含 rule/severity/source/stage/ref,供周报按同 PR/head 取最新、唯一规则计数去重), + reason 原文不入事件;trace 沿用当前分支,不混入被审计 PR 的事件链。emit 永不抛,组装失败同样 + 只丢弃——退出码与 stdout/stderr 不受影响(C0 三态等价)。 + """ + try: + findings = report.get("findings") or [] + summary = {"pr": report.get("pr"), "head_sha": report.get("head_sha"), "ok": report.get("ok"), + "severity": dict(Counter(item.get("severity") for item in findings)), + "rule": dict(Counter(item.get("rule") for item in findings)), + "coverage": report.get("coverage")} + outputs: dict = {} + _flatten_outputs("", summary, outputs) + events.emit(stage="ci", step="audit.summary", status="ok" if report.get("ok") else "fail", + outputs=outputs) + for item in findings: + events.emit(stage="ci", step="audit.finding", status="fail", + outputs={"pr": report.get("pr"), "head_sha": report.get("head_sha"), + "rule": item.get("rule"), "severity": item.get("severity"), + "source": item.get("source"), "stage": item.get("stage"), + "ref": item.get("ref")}) + except Exception: # noqa: BLE001 观察旁路(C0):任何组装失败都不影响审计本身 + return + + +def inspect_pr(pr: int, *, gh=None, cwd: Path = ROOT) -> dict: + """复原并核对一个已合并 PR(C7 基础形状);PR 无法审计(未合并/整体 API 故障)抛 AuditError。 + + 报告返回前写本运行的观察事件(T404);写入失败不影响返回值与调用方。 + """ + client = GhClient() if gh is None else gh + try: + repo = events_io._repo_identity(str(client.repo())) + pull = client.api(f"repos/{repo}/pulls/{pr}") + except (RuntimeError, ValueError, AttributeError) as exc: + raise AuditError(f"读取 PR {pr} 失败:{exc}") from exc + if not isinstance(pull, dict) or pull.get("merged") is not True: + raise AuditError(f"PR {pr} 未合并或不存在(审计只覆盖已合并 PR,被拦下 PR 属 B52)") + head = pull.get("head") if isinstance(pull.get("head"), dict) else {} + trace, head_sha = head.get("ref"), head.get("sha") + merge_sha, merged_at = pull.get("merge_commit_sha"), pull.get("merged_at") + if not (isinstance(trace, str) and trace and isinstance(head_sha, str) and _SHA_RE.fullmatch(head_sha) + and isinstance(merge_sha, str) and _SHA_RE.fullmatch(merge_sha) + and isinstance(merged_at, str) and _parse_utc(merged_at) is not None): + raise AuditError(f"PR {pr} 的合并事实缺失(headRefName/head/merge_commit_sha/merged_at)") + report = _Auditor(client=client, repo=repo, pr=pr, trace=trace, head=head_sha, merge_sha=merge_sha, + merged_at=merged_at, cwd=Path(cwd)).run() + _emit_observations(report) + return report + + +# ---- CLI(cli.py COMMANDS 注册 audit;QUIET_COMMANDS 已含 audit,不追加自身事件)---- + + +def _render(report: dict) -> str: + head = report.get("head_sha") or "" + coverage = report["coverage"] + refs = coverage["references"] + lines = [(f"audit PR {report['pr']}({report.get('trace_id') or '—'}):head {head[:12] or '—'}…," + f"合并 {report.get('merged_at') or '—'}")] + lines.append(f" 覆盖:阶段 {len(report['stages'])}(local {coverage['events']['local']} / " + f"ci {coverage['events']['ci']} / github {coverage['events']['github']},链 " + f"{coverage['chains']}、锚点 {coverage['anchors']});引用 {refs['total']}" + f"(核对通过 {refs['verified']}、未核对 {refs['unchecked']}、不符 {refs['hash_mismatch']}、" + f"不可得 {refs['unavailable']}、到期 {refs['expired']});账本 {coverage['ledger']}") + if report["findings"]: + lines.append(f" 发现 {len(report['findings'])} 项:") + lines += [f" - [{item['severity']}] {item['rule']} {item['source']}/{item['stage']} " + f"{item['ref']}:{item['reason']}" for item in report["findings"]] + else: + lines.append(" 发现:无") + return "\n".join(lines) + + +def _select_merged(client, repo: str, cutoff: datetime | None, + problems: list[dict]) -> list[tuple[datetime, int]] | None: + """分页取全部已关闭 PR,按一次性确定的 UTC 窗口过滤出已合并者;列表失败返回 None。""" + pulls = github_events._collect_pages( + client, f"repos/{repo}/pulls?state=closed&sort=created&direction=desc", "已关闭 PR 列表", problems) + if pulls is None: + return None + selected: list[tuple[datetime, int]] = [] + for pull in pulls: + if not isinstance(pull, dict) or pull.get("merged") is not True: + continue + moment = _parse_utc(pull.get("merged_at")) + if moment is None or (cutoff is not None and moment < cutoff): + continue + if isinstance(pull.get("number"), int): + selected.append((moment, pull["number"])) + selected.sort() + return selected + + +def _audit_or_failure(client, repo: str, number: int) -> dict: + """批量里单个 PR 的审计;无法审计(合并事实缺失等)转成一条发现,不中断整批。""" + try: + return inspect_pr(number, gh=client, cwd=ROOT) + except AuditError: + report = {"pr": number, "repository": repo, "trace_id": None, "head_sha": None, + "merge_sha": None, "merged_at": None, "stages": [], "references": [], + "chains": [], "anchors": [], + "findings": [_finding("reference_unavailable", source="github", stage="merge", + ref=f"{repo}#{number}", reason="PR 无法审计:合并事实缺失")], + "coverage": {"events": {name: 0 for name in _SOURCE_CLASSES}, "chains": 0, + "anchors": 0, "references": {"total": 0, **{name: 0 for name in _STATUSES}}, + "ledger": "absent"}, + "ok": False} + _emit_observations(report) # 无法审计的运行同样留一条汇总与发现事件(C7 供周报) + return report + + +def _all_merged(args, as_json: bool) -> int: + """批量模式:分页全部已关闭 PR,按一次性确定的窗口过滤已合并者逐个审计。""" + client = GhClient() + try: + repo = events_io._repo_identity(str(client.repo())) + except (RuntimeError, ValueError, AttributeError) as exc: + print(f"audit:确定仓库失败:{exc}", file=sys.stderr) + return 2 + try: + cutoff = _parse_window(args.since) if args.since else None + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + problems: list[dict] = [] + selected = _select_merged(client, repo, cutoff, problems) + if selected is None: + for item in problems: + print(f"audit:{item['detail']}", file=sys.stderr) + return 2 + until = _parse_utc(events_db._now()) or datetime.now(UTC) + reports = [_audit_or_failure(client, repo, number) for _moment, number in selected] + for report in reports: + if not as_json: + print("\n".join(f" {line}" for line in _render(report).splitlines())) + window = {"since": cutoff.astimezone(UTC).isoformat() if cutoff else None, + "until": until.astimezone(UTC).isoformat()} + if as_json: + print(json.dumps({"window": window, "prs": reports}, ensure_ascii=False, indent=2)) + else: + print(f"audit --all-merged:窗口 {window['since'] or '全部'} → {window['until']}," + f"共 {len(reports)} 个已合并 PR") + return max((_exit_code(report) for report in reports), default=0) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="audit", description="审计引用复原与哈希核对(设计 4.2)") + parser.add_argument("pr", nargs="?", type=int, help="审计指定已合并 PR") + parser.add_argument("--all-merged", action="store_true", help="批量审计时间窗口内的全部已合并 PR") + parser.add_argument("--since", metavar="时长|UTC", help="批量模式的窗口起点(如 30d 或 ISO 时间)") + parser.add_argument("--json", action="store_true", help="输出 JSON") + args = parser.parse_args(argv) + if args.pr is not None and args.all_merged: + print("PR 号与 --all-merged 二选一(两种模式互斥)", file=sys.stderr) + return 2 + if args.since is not None and not args.all_merged: + print("--since 只与 --all-merged 同用", file=sys.stderr) + return 2 + if args.pr is None and not args.all_merged: + parser.print_usage(sys.stderr) + print("audit:缺少审计目标(PR 号或 --all-merged)", file=sys.stderr) + return 2 + if args.pr is not None: + try: + report = inspect_pr(args.pr, cwd=ROOT) # ROOT 在调用时解析(审计所在仓库) + except AuditError as exc: + print(f"audit:{exc}", file=sys.stderr) + return 2 + print(json.dumps(report, ensure_ascii=False, indent=2) if args.json else _render(report)) + return _exit_code(report) + return _all_merged(args, args.json) diff --git a/.harness/engine/reports/audit_completeness.py b/.harness/engine/reports/audit_completeness.py new file mode 100644 index 0000000..5253cd7 --- /dev/null +++ b/.harness/engine/reports/audit_completeness.py @@ -0,0 +1,313 @@ +"""审计完整性与防篡改判定(B46 T402,设计 4.2,共用合同 C7)。 + +在 T401 复原与逐引用核对之上补两层: + +完整性(应有环节):按风险与 PR 种类检查 R2+ 有独立评审且身份不同、自动合并有受信任 CI 的 +route.result(outputs.auto_merge=true)、任务 PR 有运行记录与阶段锚点、app 模式 R0/R1 自动合并由 +批准 App 批准且绑定合并 head;none 模式明确无 App 适用,不凭空要求批准。风险取受信任 CI 链上最新 +route.facts 的机器判定;任务 PR=route.facts 声明任务书类别 ∨ 评审摘要列有执行方 ∨ 已有运行记录 +(2026-10-02 任务书修订口径),设计方 PR 不要求派发记录。「自动合并」取合并事实的 merger_type=Bot +(两种模式的自动合并工作流都以机器人身份合并,人工合并是 User);风险不可判定时对应规则不适用, +缺失的 CI 事实本身已由引用核对与 missing_route 报告。 + +防篡改:校验各 (source, trace) 原始链(events_db.verify);每个固定锚点(运行记录/CI artifact/PR +评论)核对「对应前缀链上有该链头」而非最终链头——合法后续追加不误报,删尾即使内部链仍合法也能由 +固定锚点发现;账本链头与运行层一致(账本固定后的合法追加不误报)。链未知或不可得不当完整:事件库 +不可读产生如实发现并按链不可校验处理,不当「无事件」放过。 + +checks.toml 可选 [audit]:require_review_risk(0..3,缺省 2)、require_route_for_auto、 +require_run_record_for_task、verify_anchors(布尔,缺省 true)。缺省执行设计规则;未知键、类型或 +取值非法产生 configuration_error 发现(CLI 退出 2),不静默放宽——非法键按设计缺省执行并在报告中 +指明。配置只影响 audit 报告:本模块只在审计时被调用,policy/guard/verify 判定不经过这里。 +""" + +from __future__ import annotations + +import re + +from engine.core import events_db +from engine.core.common import load_checks +from engine.routing import policy + +# [audit] 已知键与设计缺省(C7);缺省值即缺省执行的设计规则。 +_DEFAULTS = {"require_review_risk": 2, "require_route_for_auto": True, + "require_run_record_for_task": True, "verify_anchors": True} +_CONFIG_REF = ".harness/config/checks.toml [audit]" +_RISK_RE = re.compile(r"R([0-3])\Z") +# T201 运行记录固定的锚点;missing_anchor 只认它,CI/PR 评论锚点是额外固定点,不豁免记录锚点。 +_RECORD_FIXED_IN = "run_record" + + +def _finding(rule: str, *, source: str, stage: str, ref: str, reason: str) -> dict: + """C7 finding(severity 全部 error):reason 只写分类与事实,不回显引用内容。""" + return {"rule": rule, "severity": "error", "source": source, "stage": stage, + "ref": ref, "reason": reason} + + +def load_config() -> tuple[dict, list[dict]]: + """读 [audit] 并校验(C7):返回(生效配置, configuration_error 发现);非法键按设计缺省执行。""" + config = dict(_DEFAULTS) + try: + section = load_checks().get("audit", {}) + except Exception as exc: # noqa: BLE001 配置损坏明确报告,不静默放宽 + return config, [_finding("configuration_error", source="local", stage="audit", ref=_CONFIG_REF, + reason=f"配置无法读取({type(exc).__name__}),按设计缺省执行")] + if section is None: + section = {} + if not isinstance(section, dict): + return config, [_finding("configuration_error", source="local", stage="audit", ref=_CONFIG_REF, + reason="[audit] 不是配置表,按设计缺省执行")] + problems: list[dict] = [] + for key, value in section.items(): + if key not in _DEFAULTS: + problems.append(_finding("configuration_error", source="local", stage="audit", ref=_CONFIG_REF, + reason=f"未知配置键 {key},按设计缺省执行")) + elif key == "require_review_risk": + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 3: + problems.append(_finding("configuration_error", source="local", stage="audit", + ref=_CONFIG_REF, reason="require_review_risk 须是 0..3 的整数")) + else: + config[key] = value + elif not isinstance(value, bool): + problems.append(_finding("configuration_error", source="local", stage="audit", ref=_CONFIG_REF, + reason=f"{key} 须是布尔值")) + else: + config[key] = value + return config, problems + + +def _risk_level(label) -> int | None: + """route.facts 的机器判定 R0..R3 → 0..3;缺失或形状不符返回 None(无法判定)。""" + if not isinstance(label, str): + return None + match = _RISK_RE.fullmatch(label.strip().upper()) + return int(match[1]) if match else None + + +def _source_class(source: str) -> str: + if str(source).startswith("github:"): + return "github" + if str(source).startswith("ci"): + return "ci" + return "local" + + +def _trusted_ci(row: dict) -> bool: + """受信任 CI 链(C5 导入时已核对 origin head):ci: 前缀。local 链不冒充 main 判定。""" + return str(row.get("source") or "").startswith("ci") + + +def _outputs(row: dict | None) -> dict: + value = row.get("outputs") if isinstance(row, dict) else None + return value if isinstance(value, dict) else {} + + +class _Facts: + """从复原数据提炼的适用性事实:规则只依赖这些判定,不各自翻原始行。""" + + def __init__(self, auditor): + rows = auditor.event_rows or [] + trusted_route = [row for row in rows if row.get("stage") == "route" and _trusted_ci(row)] + facts_rows = [row for row in trusted_route if row.get("step") == "facts"] + latest_facts = max(facts_rows, key=lambda row: str(row.get("ts")), default=None) + self.risk = _risk_level(_outputs(latest_facts).get("risk")) + declared = _outputs(latest_facts).get("declared_class") + self.reviews = [stage for stage in auditor.stages or [] + if stage.get("evidence") == "review_comment_summary"] + merge_rows = [row for row in rows + if row.get("stage") == "merge" and row.get("step") == "github.merge"] + latest_merge = max(merge_rows, key=lambda row: str(row.get("ts")), default=None) + merge_out = _outputs(latest_merge) + self.auto_merged = merge_out.get("merger_type") == "Bot" + self.approval_out = merge_out + self.route_result_ok = any(_outputs(row).get("auto_merge") is True + for row in trusted_route if row.get("step") == "result") + self.run_records = any(stage.get("evidence") == "run_record_summary" + for stage in auditor.stages or []) + # 任务 PR 三证据(任务书修订口径):声明类别 ∨ 评审列有执行方 ∨ 已有运行记录,任一即成立。 + implemented = any(isinstance(stage.get("implementers"), list) and stage["implementers"] + for stage in self.reviews) + self.task_pr = declared is not None or implemented or self.run_records + self.record_anchors = [anchor for anchor in auditor.anchors or [] + if anchor.get("fixed_in") == _RECORD_FIXED_IN] + + +def _independent(review: dict) -> bool: + """评审身份独立:independent 不为 False,且评审者不同于设计方、不在执行方列表里(不区分大小写)。""" + if review.get("independent") is False: + return False + reviewer = str(review.get("reviewer") or "").strip().lower() + designer = str(review.get("designer") or "").strip().lower() + implementers = {str(item).strip().lower() for item in review.get("implementers") or [] + if isinstance(item, str)} + return bool(reviewer) and reviewer != designer and reviewer not in implementers + + +def _review(facts: _Facts, config: dict, ref: str) -> list[dict]: + """missing_review(无合格独立评审)与 reviewer_not_independent(逐条身份冲突)。""" + findings: list[dict] = [] + if facts.risk is not None and facts.risk >= config["require_review_risk"] \ + and not any(_independent(review) for review in facts.reviews): + reason = "没有评审摘要,应有独立评审" if not facts.reviews else "现有评审身份不独立,无合格独立评审" + findings.append(_finding("missing_review", source="github", stage="review", ref=ref, reason=reason)) + for review in facts.reviews: + if _independent(review): + continue + comment = review.get("comment") if isinstance(review.get("comment"), dict) else {} + where = f"{ref}/comments/{comment['id']}" if comment.get("id") is not None else ref + findings.append(_finding("reviewer_not_independent", source="github", stage="review", ref=where, + reason="评审者与设计方或执行方身份相同(或 independent 显式为 false)")) + return findings + + +def _route(facts: _Facts, config: dict, ref: str) -> list[dict]: + """missing_route:自动合并必须有受信任 CI 链上 auto_merge=true 的 route.result。""" + if config["require_route_for_auto"] and facts.auto_merged and not facts.route_result_ok: + return [_finding("missing_route", source="ci", stage="route", ref=ref, + reason="自动合并没有受信任 CI 来源(ci: 前缀)的 route.result(auto_merge=true)")] + return [] + + +def _record_anchors(facts: _Facts, config: dict, ref: str) -> list[dict]: + """missing_run_record / missing_anchor:任务 PR 必须有运行记录,且记录固定了阶段锚点。""" + if not config["require_run_record_for_task"] or not facts.task_pr: + return [] + if not facts.run_records: + return [_finding("missing_run_record", source="local", stage="dispatch", ref=ref, + reason="任务 PR 在合并 head 上没有运行记录")] + if not facts.record_anchors: + return [_finding("missing_anchor", source="local", stage="dispatch", ref=ref, + reason="运行记录没有固定的阶段锚点(fixed_in=run_record)")] + return [] + + +def _approval(facts: _Facts, config: dict, mode: str | None, ref: str) -> list[dict]: + """approval_actor / approval_head:app 模式 R0/R1 自动合并须批准者 App 且绑定合并 head。 + + none 模式无 App 适用(不凭空要求批准);人工合并不是自动合并,同样不适用。 + """ + if mode != "app" or not facts.auto_merged: + return [] + if facts.risk is None or facts.risk > 1: + return [] + findings: list[dict] = [] + if facts.approval_out.get("approver_type") != "Bot": + findings.append(_finding("approval_actor", source="github", stage="merge", ref=ref, + reason=f"app 模式 R{facts.risk} 自动合并的批准者不是 App" + f"(approver_type={facts.approval_out.get('approver_type')})")) + if facts.approval_out.get("approval_bound") is not True: + detail = "批准没有绑定提交" if facts.approval_out.get("approval_commit") is None \ + else "批准绑定的提交不是合并的 head" + findings.append(_finding("approval_head", source="github", stage="merge", ref=ref, reason=detail)) + return findings + + +def _chains(auditor, config: dict) -> list[dict]: + """chain_invalid(原始链校验失败);锚点核对见 _anchors。""" + findings: list[dict] = [] + hashes: dict[str, set] = {} + last_stage: dict[str, str] = {} + last_seq: dict[str, int] = {} + for row in auditor.event_rows or []: + source = str(row.get("source") or "") + hashes.setdefault(source, set()).add(row.get("hash")) + if source not in last_seq or int(row.get("seq") or 0) > last_seq[source]: + last_seq[source] = int(row.get("seq") or 0) + last_stage[source] = str(row.get("stage") or "dispatch") + if getattr(auditor, "db_error", None): + findings.append(_finding("chain_invalid", source="local", stage="audit", ref="harness.db", + reason="事件库无法读取,链与锚点不可校验(不当完好)")) + for chain in auditor.chains or []: + source = str(chain.get("source") or "") + try: + problems = events_db.verify(trace_id=chain.get("trace_id"), source=source) + except Exception as exc: # noqa: BLE001 坏库:链不可校验要如实报告 + problems = [f"链校验无法执行({type(exc).__name__})"] + if problems: + findings.append(_finding("chain_invalid", source=_source_class(source), + stage=last_stage.get(source, "dispatch"), + ref=f"{source}/{chain.get('trace_id')}", + reason=f"原始链校验失败:{problems[0]}")) + return findings + _anchors(auditor, config, hashes) + + +def _anchors(auditor, config: dict, hashes: dict[str, set]) -> list[dict]: + """anchor_mismatch:固定锚点按前缀成员核对——锚点之后的合法追加不算篡改,链头消失即发现。""" + if not config["verify_anchors"]: + return [] + findings: list[dict] = [] + seen: set[tuple] = set() + for anchor in auditor.anchors or []: + key = (anchor.get("source"), anchor.get("stage"), anchor.get("head_hash"), anchor.get("fixed_in")) + if key in seen: + continue + seen.add(key) + known = hashes.get(str(anchor.get("source") or "")) + if known is not None and anchor.get("head_hash") in known: + continue # 前缀成员:链在锚点之后继续增长属合法追加 + findings.append(_finding("anchor_mismatch", source=_source_class(anchor.get("source") or ""), + stage=str(anchor.get("stage") or "dispatch"), + ref=f"{anchor.get('fixed_in')}:{anchor.get('stage')}" + f"@{str(anchor.get('head_hash') or '')[:12]}", + reason="固定锚点的链头不在对应链上(链缺失或锚点之后的历史被改动)")) + return findings + + +def _ledger(auditor) -> list[dict]: + """ledger_mismatch:账本链头与运行层一致;账本固定后的合法追加(链头仍是前缀成员)不误报。""" + doc = getattr(auditor, "ledger_doc", None) + if not isinstance(doc, dict): + return [] # 账本缺失/不可解析由 T401 的引用核对报告,不在这里重复 + hashes: dict[str, set] = {} + for row in auditor.event_rows or []: + hashes.setdefault(str(row.get("source") or ""), set()).add(row.get("hash")) + findings: list[dict] = [] + for chain in doc.get("chains") or []: + if not isinstance(chain, dict): + findings.append(_finding("ledger_mismatch", source="github", stage="merge", ref="ledger:chains", + reason="账本链条目形状不符")) + continue + source, head = str(chain.get("source") or ""), chain.get("head_hash") + known = hashes.get(source) + if not known: + continue # 运行层没有该链:链不可得由引用核对/missing_route 报告,不在这里猜 + if head == _chain_head(auditor, source, chain.get("trace_id")) or head in known: + continue + findings.append(_finding("ledger_mismatch", source="github", stage="merge", + ref=f"ledger:{source}/{chain.get('trace_id')}", + reason="账本链头不在运行层对应链上(账本与运行层不一致)")) + return findings + + +def _chain_head(auditor, source: str, trace_id) -> str | None: + """运行层该链当前链头;库里没有事件或读取失败返回 None(调用方再按前缀成员兜底)。""" + try: + return events_db.chain_head(source, trace_id or auditor.trace) + except Exception: # noqa: BLE001 坏库:与 None 同样交由前缀成员判定 + return None + + +def _approval_mode(findings: list[dict]) -> str | None: + """被审计仓库的批准方式(与自动合并工作流同一配置);配置损坏记 configuration_error 并跳过审批核对。""" + try: + return policy.platform_outputs()["approval"] + except Exception as exc: # noqa: BLE001 平台配置非法是配置错误,不是「none」 + findings.append(_finding("configuration_error", source="local", stage="audit", + ref=".harness/config/checks.toml [platform]", + reason=f"批准方式配置非法({type(exc).__name__}),审批核对未执行")) + return None + + +def check(auditor) -> list[dict]: + """完整性与防篡改 findings(在 T401 复原核对之后调用;只读,不改复原结果)。""" + config, findings = load_config() + mode = _approval_mode(findings) + facts = _Facts(auditor) + ref = f"{auditor.repo}#{auditor.pr}" + findings += _review(facts, config, ref) + findings += _route(facts, config, ref) + findings += _record_anchors(facts, config, f"docs/runs@{auditor.merge_sha}") + findings += _approval(facts, config, mode, ref) + findings += _chains(auditor, config) + findings += _ledger(auditor) + return findings diff --git a/.harness/engine/reports/ci_events.py b/.harness/engine/reports/ci_events.py new file mode 100644 index 0000000..746eabb --- /dev/null +++ b/.harness/engine/reports/ci_events.py @@ -0,0 +1,191 @@ +"""CI 工作流的事件导出与 summary(B46 T303):只被 harness/auto-merge 工作流模板调用,不注册进 +cli.py(无 CLI 注册冲突;导出与 summary 是观察旁路,查询与渲染不追加自身事件,共用合同 C1)。 + + python .harness/engine/reports/ci_events.py export --target <目录> + 先把当前链(default_source())各 trace 的链头固定为 ci_artifact 锚点(设计 2.5:锚点随 + artifact 上传即被固定),再把该链的 EventBundle v1 写成 <目录>/harness-events.json,并把 + manifest 里的安全 JSON 内容产物复制进同一目录;目录先清空再写,上传集合因此精确等于 + 「bundle + 安全 manifest」,临时库 db/WAL/SHM 与本机原始日志不进入。缺库/无事件导出空包; + 被排除的产物以 findings 记进包内并向 stderr 提示,不中断导出。 + + python .harness/engine/reports/ci_events.py summary --bundle <文件> + 从导出的 bundle 渲染 verify/route 事件的 Markdown summary(检查/规则、状态、依据与理由、 + 错误)到 stdout,供工作流追加进 $GITHUB_STEP_SUMMARY。只读展示层:与原机器输出同源(同一 + 事件数据),保留既有步骤自己的 summary 输出与状态检查名,不替换、不改判定。 + +两种调用方式都须支持安装布局:直接脚本把包根(安装布局的 .harness、引擎仓库的仓库根)插入 +sys.path;`python -m engine.reports.ci_events` 在 .harness 目录(或 PYTHONPATH 含它)下运行。 +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from pathlib import Path + +# 直接脚本运行时 sys.path[0] 是 reports 目录,先把包根插进去才能导入 engine 包; +# -m 方式下包根已在 sys.path,此行幂等。 +_PACKAGE_ROOT = str(Path(__file__).resolve().parents[2]) +if _PACKAGE_ROOT not in sys.path: + sys.path.insert(0, _PACKAGE_ROOT) + +from engine.core import events, events_db, events_io # 导入须在上面的 sys.path 准备之后 + +BUNDLE_NAME = "harness-events.json" # 逻辑名 harness-events 的包文件(artifact 内唯一 JSON 成员) +ANCHOR_STAGE = "ci" # 锚点在 CI 作业导出时固定,记 ci 阶段 +ANCHOR_FIXED_IN = "ci_artifact" # 链头固定处:CI 事件 artifact(events.set_anchor 的枚举值) +SUMMARY_STAGES = ("verify", "route") # summary 只渲染判定与路由事件(T303 目标终态) + + +# ---- export:链头锚点、EventBundle 与安全内容产物 ---- + +def _chains(source: str) -> list[tuple[str, str]]: + """当前来源参与导出的链(按链首次出现序);查询只读。""" + chains: list[tuple[str, str]] = [] + for row in events_io.query(source=source): + key = (row["source"], row["trace_id"]) + if key not in chains: + chains.append(key) + return chains + + +def _fix_anchors(source: str) -> int: + """导出前把各链链头固定为 ci_artifact 锚点(set_anchor 永不抛异常);返回固定的锚点数。""" + fixed = 0 + for chain_source, trace in _chains(source): + head = events.chain_head(trace, chain_source) # 签名是 chain_head(trace_id, source) + if head: + events.set_anchor(trace, ANCHOR_STAGE, head, ANCHOR_FIXED_IN, chain_source) + fixed += 1 + return fixed + + +def _reset_dir(target: Path) -> None: + """导出目录先清空再建:上传集合精确等于本次的 bundle + manifest,不混入上次残留。""" + if target.exists(): + shutil.rmtree(target) + target.mkdir(parents=True) + + +def _copy_manifest(bundle: dict, target: Path) -> int: + """把 manifest 里的安全内容产物复制进导出目录(文件名即 sha256);失败抛 OSError 不当成功。""" + directory = events_db.artifacts_dir() + copied = 0 + for entry in bundle["artifacts"]: + if directory is None: + raise OSError("无本地产物目录,manifest 内容产物无法复制") + shutil.copyfile(directory / entry["file"], target / entry["file"]) + copied += 1 + return copied + + +def export_main(argv: list[str]) -> int: + """export 子命令:写 bundle 与安全内容产物;导出失败返回 1,不当成功。""" + parser = argparse.ArgumentParser(prog="ci_events export", description="导出当前链的 EventBundle 与安全内容产物") + parser.add_argument("--target", required=True, metavar="目录", + help="导出目录(存在则先清空;即 upload-artifact 的 path)") + args = parser.parse_args(argv) + source = events.default_source() + anchored = _fix_anchors(source) + bundle = events_io.export_bundle(source=source) + target = Path(args.target) + try: + _reset_dir(target) + events_io.write_bundle(bundle, target / BUNDLE_NAME) + copied = _copy_manifest(bundle, target) + except OSError as exc: + print(f"ci_events:导出失败:{exc}", file=sys.stderr) + return 1 + for finding in bundle["findings"]: + print(f"ci_events:已排除 {finding['code']}:{finding['detail']}", file=sys.stderr) + print(f"ci_events:导出 {len(bundle['events'])} 个事件、{len(bundle['anchors'])} 个锚点" + f"(本次固定 {anchored} 个)、{copied} 个内容产物 → {target / BUNDLE_NAME}") + return 0 + + +# ---- summary:从导出的 bundle 渲染 verify/route 事件 ---- + +def _sort_key(event: dict) -> tuple: + """确定性排序:时间、来源、seq(seq 缺失排前,保证任意 bundle 输出稳定)。""" + seq = event.get("seq") + return str(event.get("ts") or ""), str(event.get("source") or ""), seq if isinstance(seq, int) else 0 + + +def _decision_suffix(decision) -> str: + """依据后缀:by/rule 与理由引用;字段缺失只写有的部分。""" + if not isinstance(decision, dict): + return "" + basis = "/".join(str(decision[key]) for key in ("by", "rule") if decision.get(key) is not None) + reason = decision.get("reason") + if basis and reason is not None: + return f" · 依据 {basis}:{reason}" + if basis: + return f" · 依据 {basis}" + return f" · 依据:{reason}" if reason is not None else "" + + +def _event_line(event: dict) -> str: + """一行一个事件:检查/规则、状态、耗时、依据与理由、错误——与事件数据逐字同源。""" + duration = f"({event['duration_ms']}ms)" if isinstance(event.get("duration_ms"), int) else "" + line = f"- {event.get('stage')}/{event.get('step')} {event.get('status')}{duration}" + line += _decision_suffix(event.get("decision")) + error = event.get("error") + if isinstance(error, dict) and error.get("kind") is not None: + line += f" · 错误 {error.get('kind')}" + return line + + +def render_summary(bundle: dict) -> str: + """verify/route 事件的确定性 Markdown:同一 bundle 输出逐字节相同;只读,不改数据。""" + rows = [event for event in bundle["events"] + if isinstance(event, dict) and event.get("stage") in SUMMARY_STAGES] + rows.sort(key=_sort_key) + chains = sorted({f"{event.get('source')} / {event.get('trace_id')}" for event in rows}) + lines = ["## harness 事件 summary", "", + f"链:{';'.join(chains) if chains else '—'}|verify/route 事件 {len(rows)} 条"] + lines += [_event_line(event) for event in rows] + if bundle.get("findings"): + lines.append(f"导出时排除 {len(bundle['findings'])} 项产物(见包内 findings)") + lines.append("") + return "\n".join(lines) + + +def summary_main(argv: list[str]) -> int: + """summary 子命令:只读 bundle 文件(不依赖本机库),渲染失败/形状不符返回 2。""" + parser = argparse.ArgumentParser(prog="ci_events summary", + description="从导出的 bundle 渲染 verify/route 事件 summary") + parser.add_argument("--bundle", required=True, metavar="文件", help="export 写出的 JSON 包") + args = parser.parse_args(argv) + try: + bundle = json.loads(Path(args.bundle).read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + print(f"ci_events:读取 bundle 失败:{exc}", file=sys.stderr) + return 2 + except ValueError: + print(f"ci_events:{args.bundle} 不是有效的 JSON", file=sys.stderr) + return 2 + if not isinstance(bundle, dict) or not isinstance(bundle.get("events"), list): + print("ci_events:bundle 形状不符(应有 events 列表)", file=sys.stderr) + return 2 + print(render_summary(bundle), end="") + return 0 + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + print("用法:ci_events.py export --target <目录> | summary --bundle <文件>", file=sys.stderr) + return 2 + name, rest = argv[0], argv[1:] + if name == "export": + return export_main(rest) + if name == "summary": + return summary_main(rest) + print(f"未知子命令:{name}(export | summary)", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/engine/reports/github_events.py b/.harness/engine/reports/github_events.py new file mode 100644 index 0000000..c06fca4 --- /dev/null +++ b/.harness/engine/reports/github_events.py @@ -0,0 +1,386 @@ +"""GitHub 事实同步(B46 T304,设计 3.5):合并后从 GitHub API 取回可核对的事实写成事件。 + + python .harness/engine/reports/github_events.py [--pr <号>] [--head <提交>] [--since ] + +--pr 同步指定 PR;缺省从 push 合并提交的信息解析关联 PR(merge 形式以「Merge pull request #N from」 +开头,squash 形式主题为「标题 (#N)」;head 缺省取 GITHUB_SHA,本地为 HEAD)。手工合并与 App 合并都经 +push 到 main 到达这里,不靠 auto-merge job 独占触发。--since 只增量读取该 +时间后更新的 escape 议题(登记后顺带增量同步,不引入 watcher);省略则全量读。只被合并后 main 上的 +harness 工作流模板调用,不注册进 cli.py(观察旁路;查询与渲染不追加自身事件,共用合同 C1)。 + +merge 事件记录设计 3.5 全字段:批准者 login/type、批准绑定提交是否等于合并 head、合并者、合并方式 +(merge / squash / rebase)与合并时标签;缺批准(单账号 none)记 "none",不捏造 App;无法确定合并方式 +记 "unknown"。 +audit_sample 与 escape 按任务书用 stage=ci,不新增 STAGES 枚举。 + +每类事实各成不可变快照:source = github::<规范化事实 sha256>(键排序紧凑 JSON 的 sha256), +读取时间不进事实摘要,同输入事实同 source(同快照幂等,重复同步不新增事件),事实变化另起新链、 +不覆盖旧快照;事件按 PR 的 headRefName 归属(trace_id)。只读查询:不做任何合并、批准、推送动作; +API 失败逐项记 findings 明确报告事实缺失(退出码 1),不伪造完整事实,也不影响原有判定(工作流步骤 +continue-on-error 隔离)。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import urllib.parse +from pathlib import Path + +# 直接脚本运行时 sys.path[0] 是 reports 目录,先把包根插进去才能导入 engine 包; +# -m 方式下包根已在 sys.path,此行幂等。 +_PACKAGE_ROOT = str(Path(__file__).resolve().parents[2]) +if _PACKAGE_ROOT not in sys.path: + sys.path.insert(0, _PACKAGE_ROOT) + +from engine.core import events, events_db, events_io # 导入须在上面的 sys.path 准备之后 +from engine.core.common import git + +# 议题标签约定与 engine/routing/policy.py 的登记一致(escape/class:<类别>;audit 由 auto-merge 创建)。 +ESCAPE_LABEL = "escape" +AUDIT_LABEL = "audit" +_CLASS_PREFIX = "class:" +_MERGE_PR_RE = re.compile(r"Merge pull request #(\d+) from ") +_SQUASH_PR_RE = re.compile(r"\(#(\d+)\)$") +_REF_RE = re.compile(r"#(\d+)") +_PAGE_LIMIT = 50 +_NONE = "none" +_UNKNOWN = "unknown" + + +def _finding(code: str, detail: str) -> dict: + return {"code": code, "detail": detail} + + +def _text(value, limit: int = 120) -> str | None: + """规范化字符串(超长、本机路径、换行丢弃为 None):进入事实摘要前先过隐私口径,保证摘要可复算。""" + return events._clean_str(str(value), limit) if value is not None else None + + +class GhClient: + """缺省 gh 客户端:只读 API 查询,失败抛 RuntimeError;响应只解析、不执行。""" + + def _run(self, argv: list[str]) -> bytes: + result = subprocess.run(["gh", *argv], capture_output=True, check=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", "replace").strip() or f"gh 退出码 {result.returncode}") + return result.stdout + + def api(self, route: str): + return json.loads(self._run(["api", route])) + + def repo(self) -> str: + """仓库身份 owner/repo:优先 Actions/gh 环境变量,否则问 gh。""" + value = os.environ.get("GITHUB_REPOSITORY") or os.environ.get("GH_REPO") + if value and value.strip(): + return value.strip() + return self._run(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).decode("utf-8").strip() + + +# ---- 事实读取(只读 API,全部记录 findings、不中断同步)---- + +def _collect_pages(client, route: str, what: str, findings: list[dict]) -> list[dict] | None: + """数组型列表端点分页取全(reviews/commits/issues 返回裸数组):空页即收,50 页不收敛放弃。""" + separator = "&" if "?" in route else "?" + items: list[dict] = [] + for page in range(1, _PAGE_LIMIT + 1): + try: + batch = client.api(f"{route}{separator}per_page=100&page={page}") + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("api", f"读取{what}第 {page} 页失败:{exc}")) + return None + if not isinstance(batch, list): + findings.append(_finding("api", f"{what}响应形状不符(第 {page} 页不是列表)")) + return None + items += [item for item in batch if isinstance(item, dict)] + if not batch: + return items + findings.append(_finding("api", f"{what}分页 {_PAGE_LIMIT} 页未收敛,已放弃")) + return None + + +def _squash_pr(message: str) -> int | None: + """squash 合并提交的主题是「 (#N)」:从首行尾注解析 PR 号,非该形式返回 None。""" + match = _SQUASH_PR_RE.search(message.split("\n", 1)[0].strip()) + return int(match[1]) if match else None + + +def _resolve_pr(client, repo: str, head: str, findings: list[dict]) -> int | None: + """从 push 合并提交的信息解析关联 PR:merge 形式以「Merge pull request #N from」开头, + squash 形式(手工 squash 合并也经 push 到 main 到达)主题以「标题 (#N)」结尾。""" + try: + commit = client.api(f"repos/{repo}/commits/{head}") + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("api", f"读取提交 {head[:12]}… 失败:{exc}")) + return None + message = commit.get("commit", {}).get("message") if isinstance(commit, dict) else None + if isinstance(message, str): + merge = _MERGE_PR_RE.match(message) + if merge is not None: + return int(merge[1]) + squash = _squash_pr(message) + if squash is not None: + return squash + findings.append(_finding("no_pr", f"提交 {head[:12]}… 不是关联 PR 的合并提交,未同步")) + return None + + +def _pull(client, repo: str, pr: int, findings: list[dict]) -> dict | None: + """PR 核心事实:未合并或读取失败都不是事实,返回 None(不产出半个 PR 的快照)。""" + try: + pull = client.api(f"repos/{repo}/pulls/{pr}") + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("api", f"读取 PR {pr} 失败:{exc}")) + return None + if not isinstance(pull, dict) or pull.get("merged") is not True: + findings.append(_finding("not_merged", f"PR {pr} 未合并或响应形状不符,跳过(事实同步只覆盖已合并 PR)")) + return None + return pull + + +def _head_ref(pull: dict) -> str | None: + head = pull.get("head") + return _text(head.get("ref")) if isinstance(head, dict) else None + + +def _approval_fact(reviews: list[dict], head_sha: str | None) -> dict: + """最近一次有效批准:批准者 login/type、绑定提交与是否等于合并的 head(被合并的 PR head 提交, + 即 auto-merge --match-head-commit 的对象)。 + + 无批准(单账号 none)记 none——合并者不是批准者,不把 owner/merged_by 当批准者,更不捏造 App。 + """ + approved = [review for review in reviews if review.get("state") == "APPROVED"] + if not approved: + return {"approver": _NONE, "approver_type": _NONE, "approval_commit": None, "approval_bound": None} + last = approved[-1] # API 按时间升序返回,最后一条即最近一次批准 + user = last.get("user") if isinstance(last.get("user"), dict) else {} + commit = _text(last.get("commit_id"), 40) + return {"approver": _text(user.get("login")), "approver_type": _text(user.get("type")), + "approval_commit": commit, + "approval_bound": (commit == head_sha) if commit and head_sha else None} + + +def _is_squash_subject(message, title: str | None, pr: int) -> bool: + """单亲提交判 squash 的核对:提交首行恰为「 (#PR号)」,标题与尾注都核对上才算, + 碰巧以「(#N)」结尾的普通提交或真 rebase 的顶提交不会误判。""" + if not isinstance(message, str) or not isinstance(title, str) or not title.strip(): + return False + return message.split("\n", 1)[0].strip() == f"{title.strip()} (#{pr})" + + +def _merge_method(client, repo: str, pr: int, title: str | None, + merge_sha: str | None, findings: list[dict]) -> str: + """合并方式:双亲为 merge;单亲且主题恰为「PR 标题 (#PR号)」判 squash;单亲且 PR 多于一个 + 提交为 rebase;其余(含无法取回)如实记 unknown。""" + if not merge_sha: + return _UNKNOWN + try: + commit = client.api(f"repos/{repo}/commits/{merge_sha}") + parents = commit.get("parents") if isinstance(commit, dict) else None + message = commit.get("commit", {}).get("message") if isinstance(commit, dict) else None + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("api", f"读取合并提交 {merge_sha[:12]}… 失败:{exc}(合并方式记 unknown)")) + return _UNKNOWN + if isinstance(parents, list) and len(parents) == 2: + return "merge" + if isinstance(parents, list) and len(parents) == 1: + if _is_squash_subject(message, title, pr): + return "squash" + commits = _collect_pages(client, f"repos/{repo}/pulls/{pr}/commits", f"PR {pr} 提交列表", findings) + if commits is not None and len(commits) > 1: + return "rebase" + return _UNKNOWN + + +def _label_names(item: dict) -> list[str]: + return [_text(label.get("name")) for label in item.get("labels") or [] + if isinstance(label, dict) and _text(label.get("name"))] + + +def _mentions(item: dict, pr: int) -> bool: + """议题是否指认该 PR(标题或正文里的 #N 精确匹配;PR 本身不算 escape/audit 议题)。""" + if item.get("pull_request"): + return False + text = " ".join(item[key] for key in ("title", "body") if isinstance(item.get(key), str)) + return str(pr) in set(_REF_RE.findall(text)) + + +# ---- 快照(不可变事实,source=github::<规范化事实 sha256>,同快照幂等)---- + +def _snapshot_source(pr: int, facts: dict) -> str: + """source = github::<规范化事实摘要>:键排序紧凑 JSON 的 sha256,读取时间不进摘要。""" + canonical = json.dumps(facts, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return f"github:{pr}:{digest}" + + +def _fresh(source: str, trace: str) -> bool: + """同快照幂等:该 (source, trace) 已有事件即重复同步,不再新增相同事实事件。""" + return not events_db.read_events(source=source, trace_id=trace) + + +def _emit(stage: str, step: str, trace: str, source: str, inputs: list[dict], outputs: dict) -> bool: + return events.emit(stage, step, "ok", trace_id=trace, source=source, + actor={"role": "engine", "host": "github"}, inputs=inputs, outputs=outputs) is not None + + +def _pr_inputs(repo: str, pr: int, extra: list[tuple[str, str | None]]) -> list[dict]: + refs = [{"kind": "pr", "ref": f"{repo}#{pr}"}] + refs += [{"kind": kind, "ref": value} for kind, value in extra if value] + return refs + + +def _sync_merge(client, repo: str, pr: int, pull: dict, trace: str, + findings: list[dict], snapshots: list[dict]) -> int: + """合并事实快照:merge 事件(设计 3.5 全字段)加逐标签事件;子事实取不到记 unknown/none。""" + head = pull.get("head") if isinstance(pull.get("head"), dict) else {} + head_sha = _text(head.get("sha"), 40) + merge_sha = _text(pull.get("merge_commit_sha"), 40) + merged_by = pull.get("merged_by") if isinstance(pull.get("merged_by"), dict) else {} + labels = sorted(set(_label_names(pull))) + reviews = _collect_pages(client, f"repos/{repo}/pulls/{pr}/reviews", f"PR {pr} 评审列表", findings) + if reviews is None: + approval = {"approver": _UNKNOWN, "approver_type": _UNKNOWN, + "approval_commit": None, "approval_bound": None} + else: + approval = _approval_fact(reviews, head_sha) + method = _merge_method(client, repo, pr, pull.get("title"), merge_sha, findings) + facts = {"kind": "merge", "pr": pr, "repository": events_io._repo_identity(repo), + "head_ref": _head_ref(pull), "head_sha": head_sha, + "merge_sha": merge_sha, "merged_at": _text(pull.get("merged_at")), + "merger": _text(merged_by.get("login")), "merger_type": _text(merged_by.get("type")), + **approval, "merge_method": method, "labels": labels} + source = _snapshot_source(pr, facts) + if not _fresh(source, trace): + return 0 + inputs = _pr_inputs(repo, pr, [("merge_commit", merge_sha), + ("head", facts["head_sha"])]) + outputs = {"pr": pr, **approval, "merger": facts["merger"], "merger_type": facts["merger_type"], + "merge_method": method, "label_count": len(labels), "merged_at": facts["merged_at"]} + emitted = int(_emit("merge", "github.merge", trace, source, inputs, outputs)) + for label in labels: + emitted += int(_emit("merge", "github.merge_label", trace, source, inputs, {"pr": pr, "label": label})) + snapshots.append({"kind": "merge", "source": source}) + return emitted + + +def _sync_audit(client, repo: str, pr: int, trace: str, + findings: list[dict], snapshots: list[dict]) -> int: + """抽审事实快照:audit 议题是否指认该 PR;读不到时如实记 unknown,不伪造抽样结果。""" + issues = _collect_pages(client, f"repos/{repo}/issues?state=all&labels={AUDIT_LABEL}", + "audit 议题列表", findings) + sampled, issue_number = _UNKNOWN, None + if issues is not None: + sampled, issue_number = False, None + for item in issues: + if isinstance(item.get("number"), int) and _mentions(item, pr): + sampled, issue_number = True, item["number"] + break + source = _snapshot_source(pr, {"kind": "audit_sample", "pr": pr, + "sampled": sampled, "issue": issue_number}) + if not _fresh(source, trace): + return 0 + outputs = {"pr": pr, "sampled": sampled, "issue": issue_number} + inputs = _pr_inputs(repo, pr, [("issue", str(issue_number) if issue_number else None)]) + emitted = int(_emit("ci", "github.audit_sample", trace, source, inputs, outputs)) + snapshots.append({"kind": "audit_sample", "source": source}) + return emitted + + +def _sync_escapes(client, repo: str, pr: int, trace: str, since: str | None, + findings: list[dict], snapshots: list[dict]) -> int: + """escape 事实快照:每个指认该 PR 的 escape 议题一条,类别取 class:<类别> 标签。 + + 后登记的 escape 是新事实、另起新链,不覆盖旧快照;since 只减少读取量(增量),不影响去重。 + """ + route = f"repos/{repo}/issues?state=all&labels={ESCAPE_LABEL}" + if since: + route += f"&since={urllib.parse.quote(str(since), safe='')}" + issues = _collect_pages(client, route, "escape 议题列表", findings) + if issues is None: + return 0 + emitted = 0 + seen = 0 + for item in issues: + if not isinstance(item.get("number"), int) or not _mentions(item, pr): + continue + classes = sorted(name for name in _label_names(item) if name.startswith(_CLASS_PREFIX)) + klass = classes[0][len(_CLASS_PREFIX):] if classes else _UNKNOWN + facts = {"kind": "escape", "pr": pr, "issue": item["number"], "class": klass, + "state": _text(item.get("state")), "created_at": _text(item.get("created_at"))} + source = _snapshot_source(pr, facts) + if not _fresh(source, trace): + continue + outputs = {"pr": pr, "issue": item["number"], "class": klass, "state": facts["state"]} + emitted += int(_emit("ci", "github.escape", trace, source, + _pr_inputs(repo, pr, [("issue", str(item["number"]))]), outputs)) + seen += 1 + snapshots.append({"kind": "escape", "issues": seen}) + return emitted + + +# ---- 入口 ---- + +def _default_head() -> str | None: + """缺省 head:Actions 的 GITHUB_SHA,本地为当前 HEAD;都没有返回 None。""" + sha = os.environ.get("GITHUB_SHA") + return sha or git("rev-parse", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) or None + + +def sync(pr: int | None = None, *, head: str | None = None, since: str | None = None, gh=None) -> dict: + """同步一个已合并 PR 的 GitHub 事实,返回 {pr, trace, snapshots, findings, events}。 + + pr 缺省时从 head(缺省 GITHUB_SHA/HEAD)的合并提交信息解析;gh 供测试注入桩客户端。只读查询: + 任何 API 失败都进 findings(退出码 1 由 main 报告),已取得的事实照常成快照,缺失的不伪造。 + """ + findings: list[dict] = [] + snapshots: list[dict] = [] + result = {"pr": None, "trace": None, "snapshots": snapshots, "findings": findings, "events": 0} + client = GhClient() if gh is None else gh + try: + repo = events_io._repo_identity(str(client.repo())) + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("api", f"确定仓库失败:{exc}")) + return result + if pr is None: + pr = _resolve_pr(client, repo, head or _default_head() or "", findings) + if pr is None: + return result + pull = _pull(client, repo, pr, findings) + if pull is None: + return result + trace = _head_ref(pull) + if not trace: + findings.append(_finding("shape", f"PR {pr} 的 headRefName 缺失,事件无法归属 PR 分支")) + return result + result["pr"] = pr + result["trace"] = trace + result["events"] += _sync_merge(client, repo, pr, pull, trace, findings, snapshots) + result["events"] += _sync_audit(client, repo, pr, trace, findings, snapshots) + result["events"] += _sync_escapes(client, repo, pr, trace, since, findings, snapshots) + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="github_events", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pr", type=int, help="同步指定 PR(缺省从 push 合并提交信息解析)") + parser.add_argument("--head", metavar="提交", help="用该提交的信息解析关联 PR(缺省 GITHUB_SHA 或本地 HEAD)") + parser.add_argument("--since", metavar="UTC 时间", help="只增量读取该时间后更新的 escape 议题") + args = parser.parse_args(argv) + result = sync(args.pr, head=args.head, since=args.since) + for finding in result["findings"]: + print(f"github_events:{finding['detail']}", file=sys.stderr) + if result["pr"] is not None: + print(f"github_events:PR #{result['pr']}({result['trace']}):" + f"快照 {len(result['snapshots'])} 个、事件 {result['events']} 条") + return 1 if result["findings"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/engine/reports/ledger.py b/.harness/engine/reports/ledger.py new file mode 100644 index 0000000..ca8a3e2 --- /dev/null +++ b/.harness/engine/reports/ledger.py @@ -0,0 +1,560 @@ +"""合并账本与 PR 锚点(B46 T305,设计 4.1,共用合同 C6):把一个已合并 PR 的全部环节压成一条 +长期 JSON 账本,追加到 harness-audit 分支,并在 PR 上留一条可更新的锚点评论。 + + python .harness/engine/reports/ledger.py [--pr <号>] [--head <提交>] + +--head 缺省取 GITHUB_SHA(本地为 HEAD);--pr 缺省从合并提交信息解析(同 github_events 的解析)。 +只被合并后 main 上的受信任工作流 job 调用(引擎来自默认分支检出,不执行 PR 代码),不注册进 cli.py +(观察旁路,共用合同 C1)。 + +build_ledger(pr, *, gh=None, cwd=ROOT) 只依赖三类可复取来源,不依赖任何既有本机库(runner 上的临时库 +只作导入中转):git 里合并 head 上的运行记录(C3 摘要,标 evidence_kind=run_record_summary)、经 +T302 load_ci 幂等导入的 CI/route 事件包(C5 原始事件)、T304 github_events 同步的合并/抽审/逃逸事实 +快照,以及 PR 评论里 T105 写下的 harness-review-audit 单行 JSON(只按标记/schema v1/head/材料字段 +解析;缺标记、字段不完整、错误 head、未来版本都如实列 missing,不猜旧评论正文)。运行记录一律按 +合并 head 读取(路径@merge_sha),不用最新 head 的记录顶替。不足的环节写 missing,不捏造。 + +账本写 harness-audit/<合并 UTC 年份>/.json,仅已合并 PR(关闭未合并为 B52,不写)。原始日志、 +Pi 流、本机 SQLite 与本机内容产物一律不入账本(引用与哈希除外)。publish_ledger 用 GITHUB_TOKEN 做 +普通 fast-forward 追加:同 PR 相同字节幂等跳过;不同字节冲突不覆盖旧文件;推送竞争 fetch 后有限重试, +禁强推、禁删除。harness-audit 的 ruleset 模板只禁删除/强推、保护历史,并不阻止普通修改旧文件—— +「只追加」的边界由 writer 幂等/冲突自查保证,本模块与工作流注释都不宣称 ruleset 独自保证只追加。 +后登记 escape 由 github_events 以新 API 快照追加事件,不覆写已固定的合并账本。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# 直接脚本运行时 sys.path[0] 是 reports 目录,先把包根插进去才能导入 engine 包; +# -m 方式下包根已在 sys.path,此行幂等。 +_PACKAGE_ROOT = str(Path(__file__).resolve().parents[2]) +if _PACKAGE_ROOT not in sys.path: + sys.path.insert(0, _PACKAGE_ROOT) + +from engine.core import events, events_db, events_io # 导入须在上面的 sys.path 准备之后 +from engine.core.common import ROOT, clean_git_env, git +from engine.reports import github_events + +LEDGER_SCHEMA_VERSION = 1 +AUDIT_BRANCH = "harness-audit" # 账本专用分支(设计 4.1 方案 A,用户已审定) +ANCHOR_PREFIX = "harness-audit:" # PR 锚点评论的稳定标记前缀(后接 PR 号) +_REVIEW_RE = re.compile(r"") +_LEGACY_REVIEW_MARK = "", + "合并账本已固定(合并账本与链头锚点):", + f"- 分支:`{result['branch']}`", + f"- 文件:`{result['path']}` @ {result['commit']}", + f"- 文件 sha256:`{result['sha256']}`", + f"- 链头:{chains or '(无事件链)'}", + ]) + "\n" + + +def _upsert_anchor_comment(client, ledger: dict, result: dict, findings: list[dict]) -> str | None: + """同标记评论更新而非新增:找到 harness-audit: 标记的既有评论就 PATCH,否则 POST。""" + marker = f"" + body = _anchor_body(ledger, result) + route = f"repos/{ledger['repository']}/issues/{ledger['pr']}/comments" + pages = github_events._collect_pages(client, route, "PR 评论", findings) + existing = next((item for item in pages or [] if isinstance(item, dict) and marker in str(item.get("body"))), None) + try: + if existing is not None: + client.api(f"repos/{ledger['repository']}/issues/comments/{existing.get('id')}", + method="PATCH", payload={"body": body}) + return "updated" + client.api(route, method="POST", payload={"body": body}) + return "created" + except (RuntimeError, ValueError, AttributeError) as exc: + findings.append(_finding("comment", f"锚点评论写入失败:{exc}")) + return None + + +def publish_ledger(ledger: dict, *, gh=None) -> dict: + """把账本 fast-forward 追加到 harness-audit 分支并维护 PR 锚点评论(C6)。 + + 返回 {ok, branch, path, commit, sha256, updated, conflict, comment, findings};账本写入成功即 + ok(评论失败只记 findings);同字节幂等 updated=False;冲突与远端故障 ok=False 且不动远端。 + """ + findings: list[dict] = [] + result = {"ok": False, "branch": AUDIT_BRANCH, "path": None, "commit": None, "sha256": None, + "updated": False, "conflict": False, "comment": None, "findings": findings} + problem = _ledger_problem(ledger) + if problem is not None: + findings.append(problem) + return result + data = canonical(ledger).encode("utf-8") + b"\n" + result["sha256"] = _digest(data) + result["path"] = f"{ledger['merged_at'][:4]}/{ledger['pr']}.json" + origin = git("remote", "get-url", "origin", cwd=events_db.ROOT, check=False, isolate=True) + if not origin: + findings.append(_finding("no_origin", "仓库没有 origin 远程,无法推送账本")) + return result + work = Path(tempfile.mkdtemp(prefix="harness-audit-")) + try: + cloned = subprocess.run(["git", "clone", "--quiet", "--no-checkout", origin, str(work)], + capture_output=True, env=clean_git_env(), check=False) + if cloned.returncode != 0: + findings.append(_finding("clone", "克隆远程失败:" + cloned.stderr.decode("utf-8", "replace").strip())) + return result + status = "retry" + for _ in range(_PUSH_ATTEMPTS): + status = _attempt_append(work, ledger["pr"], result["path"], data, result) + if status != "retry": + break + if status == "retry": + findings.append(_finding("push_exhausted", f"推送竞争 {_PUSH_ATTEMPTS} 次未成功,放弃(不强推)")) + if result["commit"] is not None: + result["ok"] = True + result["comment"] = _upsert_anchor_comment(GhClient() if gh is None else gh, ledger, result, findings) + finally: + shutil.rmtree(work, ignore_errors=True) + return result + + +def main(argv: list[str] | None = None) -> int: + """工作流入口:解析 PR → build_ledger → publish_ledger;发现与缺失都明确报告。""" + parser = argparse.ArgumentParser(prog="ledger", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--pr", type=int, help="写指定 PR 的账本(缺省从合并提交信息解析)") + parser.add_argument("--head", metavar="提交", help="用该提交解析关联 PR(缺省 GITHUB_SHA 或本地 HEAD)") + args = parser.parse_args(argv) + client = GhClient() + findings: list[dict] = [] + head = args.head or os.environ.get("GITHUB_SHA") \ + or git("rev-parse", "HEAD", cwd=events_db.ROOT, check=False, isolate=True) or "" + pr = args.pr + if pr is None: + try: + repo = events_io._repo_identity(str(client.repo())) + except (RuntimeError, ValueError, AttributeError) as exc: + print(f"ledger:确定仓库失败:{exc}", file=sys.stderr) + return 1 + pr = github_events._resolve_pr(client, repo, head, findings) + if pr is None: + for finding in findings: + print(f"ledger:{finding['detail']}", file=sys.stderr) + return 1 + try: + ledger = build_ledger(pr, gh=client) + except LedgerError as exc: + print(f"ledger:{exc}", file=sys.stderr) + return 1 + for item in ledger["missing"]: + print(f"ledger:缺 {item['item']}({item['reason']}){item['detail']}") + result = publish_ledger(ledger, gh=client) + for finding in result["findings"]: + print(f"ledger:{finding['detail']}", file=sys.stderr) + if result["ok"]: + print(f"ledger:账本 {result['branch']}/{result['path']}(commit {result['commit'][:12]}…," + f"sha256 {result['sha256'][:12]}…,{'更新' if result['updated'] else '幂等命中'}," + f"锚点评论 {result['comment'] or '未写入'})") + return 0 if result["ok"] and not result["findings"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.harness/engine/reports/metrics.py b/.harness/engine/reports/metrics.py index cf0e9a3..37c3c18 100644 --- a/.harness/engine/reports/metrics.py +++ b/.harness/engine/reports/metrics.py @@ -2,7 +2,7 @@ bin/harness metrics --base origin/main [--head HEAD] [--github] [--json] ---github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 build workflow 运行(CI 轮次、失败轮次); +--github 时用 GITHUB_TOKEN 与 GITHUB_REPOSITORY 查询该分支的 CI 工作流运行(rules.toml [dispatch] ci_workflows)(CI 轮次、失败轮次); 没有凭据时标为「不可用」,不猜。评审轮次、自述不实、人工介入由人记录在交付说明中(见 docs/plans/verifiable-delivery.md 的试跑规程)。 """ @@ -17,8 +17,9 @@ from pathlib import Path from engine.checks import evidence +from engine.core import events from engine.core.cases import load_project_cases -from engine.core.common import ROOT, added_lines, git, setting +from engine.core.common import ROOT, added_lines, ci_workflows, git, setting from engine.routing import risk, run_check @@ -50,14 +51,15 @@ def github_runs(branch: str) -> dict | None: token, repo = os.environ.get("GITHUB_TOKEN"), os.environ.get("GITHUB_REPOSITORY") if not token or not repo: return None - query = urllib.parse.urlencode({"branch": branch, "per_page": 100}) + query = urllib.parse.urlencode({"branch": branch, "event": "pull_request", "per_page": 100}) request = urllib.request.Request( - f"https://api.github.com/repos/{repo}/actions/workflows/build.yml/runs?{query}", + f"https://api.github.com/repos/{repo}/actions/runs?{query}", headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, ) try: with urllib.request.urlopen(request, timeout=20) as response: - runs = json.load(response).get("workflow_runs", []) + names = set(ci_workflows()) + runs = [run for run in json.load(response).get("workflow_runs", []) if run.get("name") in names] except (OSError, ValueError): return None return { @@ -97,6 +99,24 @@ def collect(base: str, head: str = "HEAD", use_github: bool = False, cwd: Path = return data +def _record(data: dict, base: str, head: str) -> None: + """观察旁路:每个数值度量一条事件;列表度量拆成计数与逐条目,避免嵌套结构被过滤丢弃。""" + try: + inputs = [events.ref("rev", base), events.ref("rev", head)] + for key, value in data.items(): + if isinstance(value, list): + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "count": len(value)}) + for item in value: + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "item": str(item)}) + else: + events.emit(stage="ci", step="metrics", status="ok", inputs=inputs, + outputs={"metric": key, "value": value}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def render(data: dict) -> str: lines = [f"### 交付度量(`{data['base']}` → `{data['head']}`)", "", "| 指标 | 本次 |", "|---|---|"] for key, value in data.items(): @@ -117,7 +137,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--json", action="store_true") parser.add_argument("--markdown", help="追加写入该文件(CI 写 job summary)") args = parser.parse_args(argv) - data = collect(args.base, args.head, args.github) + data = collect(args.base, args.head, args.github, cwd=ROOT) + _record(data, args.base, args.head) # 观察:全部数值度量逐项 text = json.dumps(data, ensure_ascii=False, indent=2) if args.json else render(data) print(text) if args.markdown: diff --git a/.harness/engine/reports/trace.py b/.harness/engine/reports/trace.py new file mode 100644 index 0000000..7f4cb0e --- /dev/null +++ b/.harness/engine/reports/trace.py @@ -0,0 +1,455 @@ +"""events 与 trace 查询命令(可观测性 P3,设计 4.2):只读观察入口,查询不追加自身事件(共用合同 C1)。 + + python3 cli.py events [--since 1d] [--stage verify] [--status deny] [--json] + [--export 路径 | --import 路径] + 过滤展示与计数;--export/--import 走 C5 的整库导出与幂等导入(展示过滤不进导出包,筛选后 + 的断链子集不是可导入 bundle)。非法枚举/时间/组合返回 2;导入有发现返回 1。 + + python3 cli.py trace <任务编号 | PR 号 | 分支> [--ci] [--json] + 任务号经运行记录(docs/runs,task 字段须相符)与任务书文件名映射到分支;PR 号经 API + headRefName 换分支(不读检出分支,也不读 PR 正文);完整分支名即 trace。多分支同任务、或 + 分支关联多个 PR 时列出候选(attempt/链头/时间),不随意选第一条。时间线标注输入/输出引用、 + 决定、角色、时长、来源与尝试分段(运行记录窗口优先,CI 链按来源各成一段),汇总最长阶段与 + 首个失败(deny/fail/error 区分)。--ci 经 C5 load_ci 下载 PR 关联的 CI 事件包幂等导入后再 + 展示;缺本机库或下载有发现时如实报告,不当成功。 + +退出码:0 正常(含歧义候选);1 运行受阻(gh 失败、--ci 未完成或导入有发现);2 参数与解析错误。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from engine.core import events, events_db, events_io + +_TASK_RE = re.compile(r"[Tt](\d+)\Z") +_PR_RE = re.compile(r"#?(\d+)\Z") +_DURATION_RE = re.compile(r"(\d+)([dhms])\Z") +_UNITS = {"d": 86400, "h": 3600, "m": 60, "s": 1} +_FAILURES = ("fail", "deny", "error") + + +def _parse_since(text: str) -> datetime: + """--since 的取值: 相对时长或 ISO 时间戳;不合法抛 ValueError(命令转退出码 2)。""" + match = _DURATION_RE.fullmatch(text.strip()) + if match: + return datetime.now(UTC) - timedelta(seconds=int(match[1]) * _UNITS[match[2]]) + try: + parsed = datetime.fromisoformat(text.strip()) + except ValueError: + raise ValueError(f"--since 无法解析为时长或 UTC 时间:{text}") from None + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC) + + +def _parse_ts(value) -> datetime | None: + """记录/事件里的时间戳 → aware UTC;缺失或损坏返回 None(按无法分段处理)。""" + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.strip()) + except ValueError: + return None + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC) + + +# ---- events:过滤、计数与 C5 导出/导入 ---- + + +def _counts(rows: list[dict]) -> dict: + """过滤集的 stage/status 计数(跟随筛选结果,不跟总计)。""" + return {"stage": dict(sorted(Counter(row["stage"] for row in rows).items())), + "status": dict(sorted(Counter(row["status"] for row in rows).items()))} + + +def _export(target: Path) -> int: + try: + bundle = events_io.export_bundle() + events_io.write_bundle(bundle, target) + except OSError as exc: + print(f"导出失败:{exc}", file=sys.stderr) + return 1 + print(f"已导出 {len(bundle['events'])} 个事件、{len(bundle['chains'])} 条链、" + f"{len(bundle['anchors'])} 个锚点 → {target}") + return 0 + + +def _import(source: Path) -> int: + try: + bundle = json.loads(source.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + print(f"读取失败:{exc}", file=sys.stderr) + return 2 + except ValueError: + print(f"{source} 不是有效的 JSON", file=sys.stderr) + return 2 + result = events_io.import_bundle(bundle if isinstance(bundle, dict) else {}) + for finding in result["findings"]: + print(f"发现 {finding['code']}:{finding['detail']}", file=sys.stderr) + print(f"导入完成:新增 {result['imported']},跳过 {result['skipped']},发现 {len(result['findings'])} 项") + return 1 if result["findings"] else 0 + + +def events_main(argv: list[str] | None = None) -> int: + """events 子命令:过滤展示与计数;--export/--import 整库模式供工作流使用。""" + parser = argparse.ArgumentParser(prog="events", description="事件过滤展示、计数与 C5 导出/导入") + parser.add_argument("--since", metavar="时长|UTC", help="只看此后的事件:如 1d、12h 或 ISO 时间戳") + parser.add_argument("--stage", choices=events.STAGES, help="按环节过滤") + parser.add_argument("--status", choices=events.STATUSES, help="按状态过滤") + parser.add_argument("--json", action="store_true", help="输出 JSON(过滤集、计数与总计)") + parser.add_argument("--export", metavar="路径", help="导出整库 EventBundle 到该文件(不含展示过滤)") + parser.add_argument("--import", dest="import_path", metavar="路径", help="从该文件幂等导入 EventBundle") + args = parser.parse_args(argv) + if (args.export or args.import_path) and (args.since or args.stage or args.status): + print("--export/--import 是整库模式,不与 --since/--stage/--status 同用", file=sys.stderr) + return 2 + if args.export and args.import_path: + print("--export 与 --import 只能二选一", file=sys.stderr) + return 2 + if args.export: + return _export(Path(args.export)) + if args.import_path: + return _import(Path(args.import_path)) + try: + since = _parse_since(args.since) if args.since else None + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + filtered = args.since is not None or args.stage is not None or args.status is not None + rows = sorted(events_io.query(since=since, stage=args.stage, status=args.status), + key=lambda row: (row["ts"], row["source"], row["seq"])) + total = len(events_io.query()) if filtered else len(rows) + if args.json: + print(json.dumps({"count": len(rows), "total": total, "counts": _counts(rows), + "events": rows}, ensure_ascii=False, indent=2)) + return 0 + print(f"事件 {len(rows)} 条 / 总计 {total} 条") + for name, counted in _counts(rows).items(): + print(f"按 {name}:" + ("、".join(f"{key} {value}" for key, value in counted.items()) or "—")) + for row in rows: + duration = f" {row['duration_ms']}ms" if row["duration_ms"] is not None else "" + print(f" #{row['seq']} {row['ts']} [{row['source']}] {row['stage']}/{row['step']} " + f"{row['status']}{duration}") + return 0 + + +# ---- trace:标识解析 ---- + + +@dataclass +class Candidate: + """一个候选:分支(run_record/taskbook 来源)或 PR(push_pr 来源),带 attempt/链头/时间。""" + + branch: str + origin: str + attempt: int | None = None + ts: str | None = None + end: str | None = None + head: str | None = None + exit_status: str | None = None + pr: int | None = None + + def row(self) -> dict: + return {"branch": self.branch, "origin": self.origin, "attempt": self.attempt, "ts": self.ts, + "head": self.head, "exit": self.exit_status, "pr": self.pr} + + +def _read_record(path: Path) -> dict | None: + try: + record = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, ValueError): + return None + return record if isinstance(record, dict) else None + + +def _record_candidate(record: dict, origin: str) -> Candidate | None: + """运行记录 → 候选:branch 必填;attempt/时间/退出与链头(锚点或 stages 末项)尽力补齐。""" + branch = record.get("branch") + if not isinstance(branch, str) or not branch: + return None + head = None + if isinstance(record.get("anchors"), list) and record["anchors"]: + last = record["anchors"][-1] + head = last.get("head_hash") if isinstance(last, dict) else None + stages = record.get("stages") + if head is None and isinstance(stages, list) and stages and isinstance(stages[-1], dict): + head = stages[-1].get("head_hash") + attempt = record.get("attempt") + return Candidate(branch, origin, attempt=attempt if isinstance(attempt, int) else None, + ts=record.get("started_at") if isinstance(record.get("started_at"), str) else None, + end=record.get("ended_at") if isinstance(record.get("ended_at"), str) else None, + head=head if isinstance(head, str) else None, + exit_status=record.get("exit") if isinstance(record.get("exit"), str) else None) + + +def _scan_records(match) -> list[Candidate]: + """扫描 docs/runs 下所有运行记录,branch/trace_id 命中的转为候选;损坏或不相符的跳过。""" + candidates: list[Candidate] = [] + runs = events_db.ROOT / "docs" / "runs" + for directory in sorted(runs.glob("*")) if runs.is_dir() else []: + for path in sorted(directory.glob("*.json")) if directory.is_dir() else []: + record = _read_record(path) + if record and match(record): + candidate = _record_candidate(record, "run_record") + if candidate is not None: + candidates.append(candidate) + return candidates + + +def _task_candidates(number: int) -> list[Candidate]: + """任务号 → 候选分支:运行记录(task 字段须为 T<编号>)加任务书文件名映射(task/)。""" + padded = f"{number:03d}" + candidates = [item for item in _scan_records( + lambda record: record.get("task") == f"T{number}") if item.branch.startswith(f"task/{padded}-")] + plans = events_db.ROOT / "docs" / "plans" + known = {item.branch for item in candidates} + for path in sorted(plans.glob(f"task-{padded}-*.md")) if plans.is_dir() else []: + branch = f"task/{path.stem.removeprefix('task-')}" + if branch not in known: + candidates.append(Candidate(branch, "taskbook")) + known.add(branch) + return candidates + + +def _pr_candidates(trace_id: str) -> list[Candidate]: + """本机 push_pr 事件记录的 PR 候选(分支重用可能先后关联多个 PR)。""" + candidates: list[Candidate] = [] + for row in events_io.query(trace_id=trace_id, stage="dispatch"): + outputs = row["outputs"] if isinstance(row["outputs"], dict) else {} + pr = outputs.get("pr") + if row["step"] == "push_pr" and isinstance(pr, int): + head = outputs.get("head") + candidates.append(Candidate(trace_id, "push_pr", ts=row["ts"], + head=head if isinstance(head, str) else None, pr=pr)) + return candidates + + +def _resolve(identifier: str) -> dict: + """标识 → 解析视图(不触网):任务号查映射,PR 号留待 API,分支即 trace。""" + if match := _TASK_RE.fullmatch(identifier): + number = int(match[1]) + candidates = _task_candidates(number) + branches = {item.branch for item in candidates} + ambiguous = len(branches) > 1 + return {"kind": "task", "identifier": identifier, "number": number, "candidates": candidates, + "ambiguous": ambiguous, + "trace_id": None if ambiguous else (candidates[0].branch if candidates else None)} + if match := _PR_RE.fullmatch(identifier): + return {"kind": "pr", "identifier": identifier, "number": int(match[1])} + if identifier and not identifier.startswith("-"): + return {"kind": "branch", "identifier": identifier, "trace_id": identifier} + return {"kind": "unknown", "identifier": identifier} + + +# ---- trace:时间线组装与展示 ---- + + +def _ci_attempt(source: str) -> int | None: + """ci::: 来源链里的 attempt;其余来源为 None。""" + parts = source.split(":") + return int(parts[2]) if len(parts) == 4 and parts[0] == "ci" and parts[2].isdigit() else None + + +def _segment_events(rows: list[dict], records: list[Candidate]) -> tuple[list[dict], dict[int, dict]]: + """时间线分段:本机运行记录窗口(attempt/链头/起止)优先,CI 来源链各成一段,其余未分段。""" + windows = [(item, _parse_ts(item.ts), _parse_ts(item.end)) for item in records + if item.origin == "run_record"] + windows = [(item, start, end) for item, start, end in windows if start and end] + buckets: dict[str, dict] = {} + labels: dict[int, dict] = {} + for index, row in enumerate(rows): + if (source := row["source"]).startswith("ci:"): + key, attempt, head = source, _ci_attempt(source), None + else: + moment = _parse_ts(row["ts"]) + hit = next((item for item, start, end in windows + if moment is not None and start <= moment <= end), None) + key = f"attempt {hit.attempt}" if hit is not None else "本机(窗口外)" + attempt = hit.attempt if hit is not None else None + head = hit.head if hit is not None else None + labels[index] = {"attempt": attempt, "segment": key} + bucket = buckets.setdefault(key, {"segment": key, "attempt": attempt, "head": head, + "start": row["ts"], "end": row["ts"], "count": 0}) + bucket["end"], bucket["count"] = row["ts"], bucket["count"] + 1 + return sorted(buckets.values(), key=lambda item: item["start"]), labels + + +def _longest_stage(rows: list[dict]) -> dict | None: + """各环节累计时长最长的阶段(并列取字典序首个,保证确定性);无时长数据为 None。""" + totals: dict[str, int] = {} + for row in rows: + if isinstance(row["duration_ms"], int): + totals[row["stage"]] = totals.get(row["stage"], 0) + row["duration_ms"] + if not totals: + return None + stage = max(sorted(totals), key=lambda name: totals[name]) + return {"stage": stage, "duration_ms": totals[stage]} + + +def _first_failure(rows: list[dict]) -> dict | None: + """首个失败事件(rows 已按时间排序);deny/fail/error 按原状态区分,不合并。""" + for row in rows: + if row["status"] in _FAILURES: + return {"ts": row["ts"], "source": row["source"], "seq": row["seq"], + "stage": row["stage"], "step": row["step"], "status": row["status"]} + return None + + +def _timeline(trace_id: str, records: list[Candidate]) -> dict: + rows = sorted(events_io.query(trace_id=trace_id), key=lambda row: (row["ts"], row["source"], row["seq"])) + segments, labels = _segment_events(rows, records) + return {"trace_id": trace_id, "chains": sorted({row["source"] for row in rows}), + "segments": segments, "counts": _counts(rows), + "events": [dict(row, **labels[index]) for index, row in enumerate(rows)], + "longest_stage": _longest_stage(rows), "first_failure": _first_failure(rows)} + + +def _short(digest) -> str: + return digest[:7] if isinstance(digest, str) and digest else "—" + + +def _ref_text(item: dict) -> str: + text = f"{item.get('kind')}={item.get('ref')}" + if item.get("sha256"): + text += f"#{_short(item['sha256'])}" + if item.get("size") is not None: + text += f"({item['size']}B)" + return text + + +def _event_lines(row: dict) -> list[str]: + lines = [f" #{row['seq']} {row['ts']} [{row['source']}] {row['stage']}/{row['step']} {row['status']}" + + (f" {row['duration_ms']}ms" if row["duration_ms"] is not None else "")] + if row["inputs"]: + lines.append(" 输入 " + ";".join(_ref_text(item) for item in row["inputs"])) + if row["outputs"]: + lines.append(" 输出 " + ";".join(f"{key}={row['outputs'][key]}" + for key in sorted(row["outputs"], key=str))) + if row["decision"]: + lines.append(f" 决定 by={row['decision'].get('by')} rule={row['decision'].get('rule')} " + f"{row['decision'].get('reason')}") + if row["error"]: + lines.append(f" 错误 kind={row['error'].get('kind')} signature={row['error'].get('signature')}") + if row["actor"]: + model = f" 模型 {row['actor']['model']}" if row["actor"].get("model") else "" + lines.append(f" 角色 {row['actor'].get('role')}@{row['actor'].get('host')}{model}") + return lines + + +def _candidate_text(item: dict) -> str: + parts = [item["branch"]] + if item.get("pr") is not None: + parts.append(f"PR {item['pr']}") + if item.get("attempt") is not None: + parts.append(f"attempt {item['attempt']}") + if item.get("head"): + parts.append(f"链头 {_short(item['head'])}") + if item.get("ts"): + parts.append(item["ts"]) + return " " + ",".join(parts) + f"(来源 {item['origin']})" + + +def _render(view: dict) -> str: + if "candidates" in view: + lines = [f"标识 {view['identifier']} 对应多个分支,未选择(用完整分支重试):"] + lines += [_candidate_text(item) for item in view["candidates"]] + return "\n".join(lines) + lines = [f"trace {view['trace_id']}(标识 {view['identifier']},{view['kind']})"] + for segment in view["segments"]: + head = f"链头 {_short(segment['head'])}," if segment.get("head") else "" + attempt = f"attempt {segment['attempt']}," if segment.get("attempt") is not None else "" + lines.append(f" {segment['segment']}:{attempt}{head}{segment['start']}→{segment['end']}" + f"({segment['count']} 个事件)") + for row in view["events"]: + lines += _event_lines(row) + longest = view["longest_stage"] + lines.append(" 最长阶段 " + (f"{longest['stage']}({longest['duration_ms']}ms)" if longest else "—")) + failure = view["first_failure"] + lines.append(" 首个失败 " + (f"#{failure['seq']} {failure['ts']} [{failure['source']}] " + f"{failure['stage']}/{failure['step']} {failure['status']}" + if failure else "—")) + return "\n".join(lines) + + +def _emit(view: dict, as_json: bool) -> None: + print(json.dumps(view, ensure_ascii=False, indent=2) if as_json else _render(view)) + + +def _pr_branch(number: int) -> str | None: + """PR → API headRefName(trace 权威来源);失败打印诊断返回 None。""" + try: + branch = events_io.GhClient().pr(number).get("headRefName") + except (RuntimeError, ValueError, AttributeError) as exc: + print(f"查询 PR {number} 失败:{exc}", file=sys.stderr) + return None + if not isinstance(branch, str) or not branch: + print(f"PR {number} 的 headRefName 缺失", file=sys.stderr) + return None + return branch + + +def _with_ci(view: dict, trace_id: str, pr_number: int | None) -> int: + """--ci:确定 PR(标识给定或本机 push_pr 事件)、下载导入并写进 view;返回退出码贡献。""" + known = _pr_candidates(trace_id) + prs = [pr_number] if pr_number is not None else sorted({item.pr for item in known}) + if len(prs) > 1: + # 分支重用关联了多个 PR:列候选(pr/链头/时间),不随意选一条下载 + view["pr_candidates"] = [item.row() for item in known] + print("该分支关联多个 PR,--ci 未下载;请指定 PR 号", file=sys.stderr) + return 1 + if not prs: + print("本机没有该 trace 的 push_pr 事件,无法确定 PR;请直接给 PR 号", file=sys.stderr) + return 1 + view["pr"] = prs[0] + try: + result = events_io.load_ci(prs[0], gh=events_io.GhClient()) + except Exception as exc: # noqa: BLE001 下载失败转诊断,不替代时间线 + result = {"imported": 0, "skipped": 0, "findings": [{"code": "api", "detail": str(exc)}]} + view["ci"] = result + for finding in result["findings"]: + print(f"CI 发现 {finding['code']}:{finding['detail']}", file=sys.stderr) + return 1 if result["findings"] else 0 + + +def trace_main(argv: list[str] | None = None) -> int: + """trace 子命令:解析任务/PR/分支 → 时间线;--ci 先下载导入该 PR 的 CI 事件包。""" + parser = argparse.ArgumentParser(prog="trace", description="按任务编号、PR 号或分支复原交付时间线") + parser.add_argument("identifier", help="任务编号(T402)、PR 号(77 或 #77)或完整分支名") + parser.add_argument("--ci", action="store_true", help="先下载并导入该 PR 的 CI 事件包(C5 load_ci)") + parser.add_argument("--json", action="store_true", help="输出 JSON") + args = parser.parse_args(argv) + resolved = _resolve(args.identifier) + if resolved["kind"] == "unknown": + print(f"无法识别的标识:{args.identifier!r}(任务编号 T / PR 号 / 完整分支)", file=sys.stderr) + return 2 + if resolved["kind"] == "task" and resolved["ambiguous"]: + _emit({"identifier": resolved["identifier"], "kind": "task", "ambiguous": True, + "trace_id": None, "candidates": [item.row() for item in resolved["candidates"]]}, + args.json) + return 0 + if resolved["kind"] == "task" and resolved["trace_id"] is None: + print(f"找不到任务 T{resolved['number']:03d} 的运行记录或任务书", file=sys.stderr) + return 1 + pr_number = None + if resolved["kind"] == "pr": + branch = _pr_branch(resolved["number"]) + if branch is None: + return 1 + resolved["trace_id"], pr_number = branch, resolved["number"] + trace_id = resolved["trace_id"] + view: dict = {"identifier": args.identifier, "kind": resolved["kind"], "trace_id": trace_id, + "pr": pr_number, "ambiguous": resolved.get("ambiguous", False)} + code = _with_ci(view, trace_id, pr_number) if args.ci else 0 + # 时间线在导入之后组装:--ci 新导入的事件同屏展示 + records = _scan_records(lambda record: trace_id in (record.get("branch"), record.get("trace_id"))) + view.update({"records": [item.row() for item in records], **_timeline(trace_id, records)}) + _emit(view, args.json) + return code + + diff --git a/.harness/engine/reports/weekly.py b/.harness/engine/reports/weekly.py index 6614150..bae1e43 100644 --- a/.harness/engine/reports/weekly.py +++ b/.harness/engine/reports/weekly.py @@ -22,7 +22,7 @@ from pathlib import Path from engine.checks import r1_checks -from engine.core.common import ROOT, git, setting +from engine.core.common import ROOT, ci_workflows, git, setting from engine.reports import metrics from engine.routing import policy, risk @@ -120,8 +120,10 @@ def issues(label: str) -> list[dict]: for pr in week.prs: week.modified_tests += _modifies_tests(pr, cwd) if pr["headRefName"] in dispatched: - builds = json.loads(gh("run", "list", "--workflow", "build", "--branch", pr["headRefName"], "--limit", "100", - "--json", "headSha,status,event")) + builds = [] + for workflow in ci_workflows(): + builds += json.loads(gh("run", "list", "--workflow", workflow, "--branch", pr["headRefName"], + "--limit", "100", "--json", "headSha,status,event")) week.ci_rounds[pr["number"]] = len({run["headSha"] for run in builds if run["status"] == "completed" and run["event"] == "pull_request"}) return week diff --git a/.harness/engine/routing/policy.py b/.harness/engine/routing/policy.py index cc64e3c..5b44ac9 100644 --- a/.harness/engine/routing/policy.py +++ b/.harness/engine/routing/policy.py @@ -9,8 +9,8 @@ 3. 预算 该类最近 window 次合并中的 escape 议题不超过 max_escapes;PR 不带 budget-exceeded 标签 4. 规模 增删行数(不计黄金快照与运行记录)不超过 autonomy.toml [size] max_lines 5. 运行记录 实现某份任务书的 PR(run_check.py):提交都带 `Task:`、有格式完整且 exit 为 ok 的运行记录、 - 已完成的 build 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 -读不到标签、议题或 build 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → + 已完成的 CI 轮次不超过任务书 budget.ci_rounds;不是在实现任务书的 PR 不要求 +读不到标签、议题或 CI 运行时按不满足处理(宁可转人审)。停机不在这里:用 Actions → auto-merge → Disable workflow(docs/specs/delivery-harness.md §8)。 K3 按 PR 编号哈希每 audit_every 个抽 1 个,合并后由工作流开 audit 议题。 @@ -30,7 +30,8 @@ from pathlib import Path from engine.checks import r1_checks, taskbook -from engine.core.common import ROOT, ConfigError, commit_field, git, path_matches, setting +from engine.core import events +from engine.core.common import ROOT, RULES_PATH, ConfigError, ci_workflows, commit_field, git, path_matches, setting from engine.routing import risk, run_check CONTRACT_PATTERNS = ["docs/plans/task-*.md", "docs/templates/**", "docs/plans/backlog.md", "docs/specs/**"] @@ -208,18 +209,21 @@ def escapes_in_window(klass: str, window: int, gh=_gh) -> tuple[int, int]: def branch_rounds(branch: str, gh=_gh) -> int | None: + """该分支已完成的 CI 轮次:ci_workflows 各工作流的 pull_request 运行,按不同的 head 提交合计。""" try: - runs = json.loads(gh("run", "list", "--workflow", "build", "--branch", branch, "--limit", "100", - "--json", "headSha,status,event")) + runs = [] + for workflow in ci_workflows(): + runs += json.loads(gh("run", "list", "--workflow", workflow, "--branch", branch, "--limit", "100", + "--json", "headSha,status,event")) except (RuntimeError, json.JSONDecodeError, OSError): return None return run_check.ci_rounds([run for run in runs if run.get("event") == "pull_request"]) def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dict | None = None, gh=_gh, - branch: str = "") -> Facts: + branch: str = "", trace: str | None = None) -> Facts: autonomy = autonomy or r1_checks.load_autonomy() - report = risk.classify(base, head, cwd) + report = risk.classify(base, head, cwd, trace_id=trace) shas = git("rev-list", f"{base}..{head}", cwd=cwd).split() defects = any(commit_field(sha, "Defect", cwd) for sha in shas) facts = Facts(report, machine_class(report, defects)) @@ -239,6 +243,71 @@ def gather(base: str, head: str, pr: int | None, cwd: Path = ROOT, autonomy: dic return facts +def _event_trace(pr: int | None, branch: str) -> str | None: + """事件 trace:--branch 优先;其次有 PR 时取 PR API 的 headRefName(不读 PR 正文);否则 current_trace()。 + + 只在事件开启时解析;查询失败回退 current_trace(),不改变原有判定与调用序列。 + """ + if not events.enabled(): + return None + if branch: + return branch + if pr is not None: + try: + name = json.loads(_gh("pr", "view", str(pr), "--json", "headRefName")).get("headRefName") + if name: + return str(name) + except (RuntimeError, json.JSONDecodeError, KeyError, TypeError, OSError): + pass + return events.current_trace() + + +def _config_refs() -> list[dict]: + """main 上 autonomy.toml 与 rules.toml 的带提交引用(路径@HEAD + 内容 sha256);单侧失败只少记一条。""" + refs = [] + for kind, path in (("autonomy", r1_checks.AUTONOMY_PATH), ("rules", RULES_PATH)): + entry = _config_ref(kind, path) + if entry: + refs.append(entry) + return refs + + +def _config_ref(kind: str, path: Path) -> dict | None: + """单个配置文件的带提交引用;读取或定位失败返回 None(观察旁路)。""" + try: + content = path.read_bytes() + rev = git("rev-parse", "HEAD", cwd=path.parent) + return {"kind": kind, "ref": f"{path.relative_to(path.parents[2]).as_posix()}@{rev}", + "sha256": hashlib.sha256(content).hexdigest(), "size": len(content)} + except Exception: # noqa: BLE001 观察旁路:引用准备失败只少记这条引用 + return None + + +def _emit_route(trace: str | None, facts: Facts, rules: list[Rule], auto: bool, audit: bool) -> None: + """路由观察事件(C1:facts、每条规则、result);事件不进判定,配置引用读取失败只少记引用。""" + if not events.enabled(): + return + try: + refs = _config_refs() + except Exception: # noqa: BLE001 观察旁路:引用准备失败不影响判定与输出 + refs = [] + events.emit("route", "facts", "ok", trace_id=trace, inputs=refs, + outputs={"risk": facts.risk.label, "machine_class": facts.machine_class, + "declared_class": facts.declared_class, "changed_lines": facts.changed_lines, + "escapes": facts.escapes, "window": facts.window}) + for rule in rules: + events.emit("route", rule.name, "ok" if rule.ok else "fail", trace_id=trace, inputs=refs, + outputs={"ok": rule.ok}, + decision={"by": "policy", "rule": rule.name, "reason": rule.reason}) + try: + approval = platform_outputs()["approval"] + except Exception: # noqa: BLE001 观察旁路:批准方式配置读不到就不记这个字段 + approval = None + events.emit("route", "result", "ok" if auto else "deny", trace_id=trace, inputs=refs, + outputs={"auto_merge": auto, "audit": audit, "approval": approval}, + decision={"by": "policy", "rule": "result", "reason": "自动合并" if auto else "转用户评审"}) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--base", required=True) @@ -248,11 +317,13 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--github", action="store_true", help="写 GITHUB_STEP_SUMMARY 与 GITHUB_OUTPUT") args = parser.parse_args(argv) autonomy = r1_checks.load_autonomy() - facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch) + trace = _event_trace(args.pr, args.branch) + facts = gather(args.base, args.head, args.pr, autonomy=autonomy, branch=args.branch, trace=trace) rules = decide(facts, autonomy) auto = all(rule.ok for rule in rules) every = autonomy.get("classes", {}).get(facts.machine_class, {}).get("audit_every", 0) audit = auto and args.pr is not None and audit_sampled(args.pr, every) + _emit_route(trace, facts, rules, auto, audit) text = render(rules, facts, audit) print(text) print(risk.render_markdown(facts.risk)) diff --git a/.harness/engine/routing/risk.py b/.harness/engine/routing/risk.py index 0ea614b..9ffc9f8 100644 --- a/.harness/engine/routing/risk.py +++ b/.harness/engine/routing/risk.py @@ -24,6 +24,7 @@ from pathlib import Path from engine.checks import r1_checks, taskbook +from engine.core import events from engine.core.common import ( ROOT, added_line_count, @@ -76,24 +77,32 @@ def label(self) -> str: return f"R{self.level}" -def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path) -> tuple[FileRisk, str | None]: +def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd: Path, + *, trace_id: str | None = None) -> tuple[FileRisk, str | None]: + """判定单个文件的等级;trace_id 非空时附带写逐文件观察事件(路径、命中规则、等级),不影响返回值。""" risk = rules["risk"] + + def record(rule: str, item: FileRisk, flag: str | None) -> tuple[FileRisk, str | None]: + events.emit("route", "risk.file", "ok", trace_id=trace_id, + outputs={"path": item.path, "status": item.status, "level": item.level}, + decision={"by": "risk", "rule": rule, "reason": item.reason}) + return item, flag + if path_matches(path, risk["golden"]): if status == "A": - return FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None - return FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`" + return record("golden", FileRisk(path, status, 0, "新增黄金快照(新增测试)"), None) + return record("golden", FileRisk(path, status, 2, "黄金快照改动 = 行为变化"), f"黄金快照改动:`{path}`") if path_matches(path, risk["tests"]): if status == "A": - return FileRisk(path, status, 0, "新增测试"), None + return record("tests", FileRisk(path, status, 0, "新增测试"), None) if status == "D": - return FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`" + return record("tests", FileRisk(path, status, 2, "删除已有测试"), f"删除已有测试:`{path}`") removed = removed_line_count(base, head, path, cwd) if removed == 0: - return FileRisk(path, status, 0, "只在已有测试文件中追加"), None - return ( - FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), - f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认", - ) + return record("tests", FileRisk(path, status, 0, "只在已有测试文件中追加"), None) + return record("tests", + FileRisk(path, status, 2, f"改动已有测试(删改 {removed} 行)"), + f"改动已有测试:`{path}`(删改 {removed} 行),判定器被修改须由评审方确认") shrinking = ( status == "M" and path_matches(path, risk.get("shrink_only", [])) @@ -101,22 +110,22 @@ def classify_file(status: str, path: str, base: str, head: str, rules: dict, cwd and removed_line_count(base, head, path, cwd) ) if shrinking: - return FileRisk(path, status, 0, "只能缩减的清单被缩减"), None + return record("shrink_only", FileRisk(path, status, 0, "只能缩减的清单被缩减"), None) pattern = path_matches(path, risk["r3"]) if pattern: - return FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 3, f"命中 R3 规则 `{pattern}`"), None) pattern = path_matches(path, risk.get("contracts", [])) if pattern: - return FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None + return record(pattern, FileRisk(path, status, 2, f"模板与待办清单须经用户审(`{pattern}`)"), None) if path_matches(path, risk.get("taskbooks", [])): - return classify_taskbook(status, path, head, cwd), None + return record("taskbook", classify_taskbook(status, path, head, cwd), None) pattern = path_matches(path, risk["r0"]) if pattern: - return FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None + return record(pattern, FileRisk(path, status, 0, f"命中 R0 规则 `{pattern}`"), None) pattern = path_matches(path, risk["r2"]) if pattern: - return FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None - return FileRisk(path, status, 2, "未归类路径按 R2"), None + return record(pattern, FileRisk(path, status, 2, f"命中 R2 规则 `{pattern}`"), None) + return record("unclassified", FileRisk(path, status, 2, "未归类路径按 R2"), None) def classify_taskbook(status: str, path: str, head: str, cwd: Path) -> FileRisk: @@ -147,11 +156,24 @@ def commits_claim_r1(base: str, head: str, cwd: Path) -> bool: return True -def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None = None) -> RiskReport: +def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None = None, *, + trace_id: str | None = None) -> RiskReport: + """判定整份改动的风险;trace_id 给出时事件用它,缺省由 emit 回退 current_trace()(旧调用仍有效)。 + + base 引用不存在(空仓库首个 PR、fresh clone 还没有 origin/main)时改动无法与 base 对比:接到 + r1_checks 的「无法核验」结果按既有降级口径处理,取 R2 并给出可读理由。 + """ rules = rules or load_rules() report = RiskReport() + if r1_checks.base_missing(base, cwd): + reason = r1_checks.unverifiable_reason(base) + report.level = 2 + report.r1_violations = [reason] + report.notes.append(f"{reason},改动无法与 base 对比,逐文件判定与 r1 加强判定都不可用 → 按降级口径取 R2") + _emit_summary(report, base, head, trace_id) + return report for status, path in changed_files(base, head, cwd): - file_risk, flag = classify_file(status, path, base, head, rules, cwd) + file_risk, flag = classify_file(status, path, base, head, rules, cwd, trace_id=trace_id) report.files.append(file_risk) if flag: report.flags.append(flag) @@ -193,9 +215,21 @@ def classify(base: str, head: str = "HEAD", cwd: Path = ROOT, rules: dict | None report.r1_violations = strengthened report.notes.append("提交声明了 `Risk: R1`,但机器核对不满足(见上方标记、非代码路径或下列理由)→ 维持原等级") report.notes += [f"- {reason}" for reason in strengthened] + _emit_summary(report, base, head, trace_id) return report +def _emit_summary(report: RiskReport, base: str, head: str, trace_id: str | None) -> None: + """风险汇总观察事件:最终等级、R1 声明与是否被降级、被改动的已有测试数(观察旁路,不影响判定)。""" + changed_tests = sum(1 for item in report.files if item.reason.startswith("改动已有测试")) + events.emit("route", "risk.summary", "ok", trace_id=trace_id, + inputs=[events.ref("rev", base), events.ref("rev", head)], + outputs={"risk": report.label, "level": report.level, "claimed_r1": report.claimed_r1, + "downgraded": report.claimed_r1 and report.level != 1, + "changed_tests": changed_tests}, + decision={"by": "risk", "rule": "risk", "reason": f"{report.label}:{POLICY[report.level]}"}) + + def render_markdown(report: RiskReport) -> str: lines = [f"### 风险等级:**{report.label}** — {POLICY[report.level]}", ""] if report.flags: diff --git a/.harness/engine/routing/run_check.py b/.harness/engine/routing/run_check.py index 5d02ad7..0399370 100644 --- a/.harness/engine/routing/run_check.py +++ b/.harness/engine/routing/run_check.py @@ -4,14 +4,16 @@ 机器再核对一遍,即使绕过派发脚本手工派发,缺记录或超预算也会被发现: 适用 分支是 task/<名字> 且 docs/plans/task-<名字>.md 存在,或任一提交带 `Task:`(实现某份任务书的 PR) + 记录内容 序号最大的一份记录全文递归检查命令正文、本机路径与会话正文(B38);命中即新增失败的 Finding, + 只写字段位置与规则名、不回显禁项,判定只依据记录自身内容、不读事件 自行实现 任务书就在本 PR 中新增或修改:设计方自己实现(用户 2026-09-28 决定,方案 B),不经派发脚本, 不要求运行记录与 CI 轮次(这类 PR 本就不自动合并),仍要求每个提交带 `Task:` 归属 范围内每个非合并提交都带 `Task: <该任务书的编号>` 运行记录 新增了 docs/runs/<任务书名>/<序号>.json;序号最大的一份格式完整、task/class/branch 与任务书一致、 exit 为 ok,提示词文件存在且 sha256 与记录一致 - CI 轮次 该分支已完成的 build 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds + CI 轮次 该分支已完成的 CI 轮次(按不同的 head 提交计)不超过任务书 budget.ci_rounds -权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);build 的 harness job 只把结果写进 summary。 +权威判定在 auto-merge 的 policy.py(main 上的代码,只读 PR 数据);CI 的 harness job 只把结果写进 summary。 bin/harness run-check --base origin/main [--head HEAD] [--branch task/005-x] [--github] """ @@ -21,6 +23,7 @@ import argparse import hashlib import json +import math import os import re import subprocess @@ -28,6 +31,7 @@ from pathlib import Path from engine.checks import taskbook +from engine.core import events, shell_structure from engine.core.common import ROOT, changed_files, commit_field, git RECORD_FIELDS = ( @@ -95,19 +99,32 @@ def check_trailers(base: str, head: str, task_id: str, cwd: Path) -> Finding: return Finding("归属", True, f"全部提交带 `Task: {task_id}`") -def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> Finding: +def _records(base: str, head: str, target: Scope, cwd: Path) -> list[tuple[int, str]]: + """范围内尚存(未删除)的运行记录,按序号排序。""" stem = Path(target.taskbook).stem - records = sorted( + return sorted( (int(match["number"]), path) for status, path in changed_files(base, head, cwd) if status != "D" and (match := RECORD_PATH.match(path)) and match["stem"] == stem ) + + +def _load_record(head: str, path: str, cwd: Path) -> tuple[object, Exception | None]: + """读取 head 上的一份记录 JSON;损坏时返回 (None, 异常)。""" + try: + return json.loads(git("show", f"{head}:{path}", cwd=cwd)), None + except (json.JSONDecodeError, RuntimeError) as error: + return None, error + + +def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> Finding: + records = _records(base, head, target, cwd) if not records: + stem = Path(target.taskbook).stem return Finding("运行记录", False, f"缺运行记录:没有新增 `docs/runs/{stem}/<序号>.json`(未经 bin/dispatch?)") number, path = records[-1] - try: - record = json.loads(git("show", f"{head}:{path}", cwd=cwd)) - except (json.JSONDecodeError, RuntimeError) as error: + record, error = _load_record(head, path, cwd) + if error is not None: return Finding("运行记录", False, f"`{path}` 不是有效的 JSON({error})") missing = [key for key in RECORD_FIELDS if key not in record] if missing: @@ -130,8 +147,408 @@ def check_record(base: str, head: str, target: Scope, branch: str, cwd: Path) -> f"重试 {record['retries']} 次") +# ---- 记录内容检查(B38/T203,共用合同 C3 字段/值表的最小判定集)---- +# +# 先对所有键/值拒绝本机路径、换行、超长、会话/命令形态与 shell 结构,再按已知字段校验声明语法; +# 未知自由文本 fail-closed。guard_denials 的键只有与内置 guard 规则理由完全相等的旧摘要可保留 +# (即使理由提及命令或连接符),且该例外仍不能含路径或换行;例外集合是 shell_structure 固定理由 +# 常量与命令表字面量的静态清单(评审 #51:不迭代规则表拼装,与常量不同步时按主线代码修订)。检查只读 +# 记录内容,不读事件库。 + +_MAX_TEXT = 200 # 与 events 的 decision.reason 上限同档:更长的字符串按超长拒绝,不截断 +_MAX_PROBLEMS = 8 # 失败原因里最多列出的命中处数(超出只报总数,不回显内容) +_KEY_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z") +_HEX_RE = re.compile(r"[0-9a-f]{6,64}\Z") +_HEX64_RE = re.compile(r"[0-9a-f]{64}\Z") +_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@/?#=+-]*\Z") # 短 ID/模型/版本/分支/相对引用/URL +_PATTERN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/@?*+-]*\Z") # kind=pattern 的规定通配模式 +_SEGMENT_RE = re.compile(r"[A-Za-z0-9.@_-]+\Z") +_TIMESTAMP_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\Z") +_NOSPACE_RE = re.compile(r"\S{1,200}\Z") +_CJK_TEXT_RE = re.compile(r"[\w #?=&/:.,@+()()!?,。:、;%*-]+\Z") +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") +_SESSION_RE = re.compile(r"(?i)\b(?:user|assistant|system|human)\s*[::]|(?:用户|助手)\s*[::]") +_EXECUTABLES = ("sh", "bash", "zsh", "python", "python3", "node", "npm", "npx", "pip", "curl", + "wget", "ssh", "scp", "make", "rm", "ls", "cat", "git", "gh") +_COMMAND_PREFIX_RE = re.compile(r"(?i)\s*(?:" + "|".join(_EXECUTABLES) + r")\s+\S") +_BARE_COMMAND_RE = re.compile(r"(?i)\s*(?:" + "|".join(_EXECUTABLES) + r")\s*\Z") +_SHELL_META = ("|", ";", "`", ">", "<", "$(", "&&", "||") +_LOCAL_PATHS = ("/Users/", "/home/", "C:\\") + +# 可信守卫理由的静态清单(旧 guard_denials 摘要例外):shell_structure 的固定理由常量(含结构化 +# 规则专有的 OVERRIDE)加上命令表 clean_keep 兜底规则的独立字面量;COMMAND_RULES/TOOL_RULES 及 +# implementer 规则的取值与这些常量一致(评审 #51:静态并入,宁可少误拒;理由本身是引擎内置 +# 常量、非记录内容)。主线上是已批准代码,CI 侧原只报告。 +_TRUSTED_GUARD_REASONS = frozenset(( + shell_structure.FILTER, shell_structure.FORCE, shell_structure.FORCE_REFSPEC, + shell_structure.BATCH, shell_structure.PUSH_MAIN, shell_structure.PUSH_TAG, + shell_structure.TAG, shell_structure.UPDATE_REF, shell_structure.NO_VERIFY, + shell_structure.NO_VERIFY_SHORT, shell_structure.HOOKS_PATH, shell_structure.OVERRIDE, + shell_structure.RESET_HARD, shell_structure.RM_OUTSIDE, shell_structure.RELEASE, + shell_structure.ISSUE_DELETE, shell_structure.LABEL_ERASE, shell_structure.ISSUE_IMPLEMENTER, + shell_structure.DELETE_REMOTE, shell_structure.API_WRITE, shell_structure.MERGE, + shell_structure.APPROVE, + "git clean -x 会删除本地运行时等被忽略的文件;用 -e 排除 checks.toml [runtime] preserve 登记的路径", +)) +# CLEAN_X 的派生理由({paths} 以「-e 路径」清单填充):按模板前缀 + 填充形态精确匹配,不是 +# starts-with/contains 例外(C06)——追加任意正文不匹配。 +_CLEAN_X_DERIVED_RE = re.compile( + re.escape(shell_structure.CLEAN_X.split("{paths}")[0]) + r"-e [A-Za-z0-9.@_/-]+(?:、-e [A-Za-z0-9.@_/-]+)*\Z") +_K_CLASSES = frozenset(f"K{level}" for level in range(8)) +_EXIT_WORDS = frozenset(("ok", "timeout", "stall", "stopped", "error", "loop", "retries", "clarify")) +_ALERT_REASONS = frozenset(( + "integrity_failure", "review_rejected", "review_error", "dispatch_stall", "dispatch_timeout", + "dispatch_loop", "dispatch_budget", "ci_last_round", "guard_denials", "budget_exhausted", + "audit_missing_stage", "audit_anchor_mismatch", +)) +_DECISION_REASONS = frozenset(events.STATUSES) | _EXIT_WORDS | _ALERT_REASONS +_FIXED_IN_VALUES = frozenset(("run_record", "ci_artifact", "pr_comment")) +_MC_CATEGORIES = frozenset(("context", "tool", "spec", "other")) +_MC_SUMMARIES = frozenset(("context_unavailable", "required_tool_unavailable", "spec_unavailable", + "spec_ambiguous", "other_missing")) +_MC_STATUSES = frozenset(("reported", "unknown", "invalid")) + + +def _forbidden(text: str, *, length: bool = True) -> str | None: + """字符串的通用禁项(换行、本机路径、超长、会话正文、命令正文、shell 结构):返回规则名或 None。""" + if "\n" in text or "\r" in text: + return "换行" + if any(marker in text for marker in _LOCAL_PATHS): + return "本机路径" + if length and len(text) > _MAX_TEXT: + return "超长" + if _SESSION_RE.search(text): + return "会话正文" + if _COMMAND_PREFIX_RE.match(text): + return "命令正文" + if any(meta in text for meta in _SHELL_META): + return "Shell 结构" + return None + + +def _is_int(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_number(value) -> bool: + return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) + + +def _repo_relative(text: str) -> bool: + """仓库相对路径(可带 @提交):拒绝绝对路径、~ 展开与路径穿越。""" + if text.startswith(("/", "~")) or ":" in text: + return False + return all(segment and segment not in (".", "..") and _SEGMENT_RE.fullmatch(segment) + for segment in text.split("/")) + + +def _ref_form(value: str) -> bool: + """引用值:仓库相对路径/带提交路径、受支持 URL 或规定短 ID。""" + return _TOKEN_RE.fullmatch(value) is not None or _repo_relative(value) + + +def _safe_value(text: str) -> bool: + """摘要位置字符串的合法值形态:哈希、短 ID/引用/URL、无空白短词或含中文的安全摘要。""" + if _HEX_RE.fullmatch(text) or _TOKEN_RE.fullmatch(text) or _NOSPACE_RE.fullmatch(text): + return True + return bool(_CJK_RE.search(text) and _CJK_TEXT_RE.fullmatch(text)) + + +def _check_str(value, location, problems, ok, rule="字段形态") -> None: + """已知字符串字段:先通用禁项,后声明语法。""" + if not isinstance(value, str): + problems.append((location, "非法类型")) + return + if hit := _forbidden(value): + problems.append((location, hit)) + return + if not ok(value): + problems.append((location, rule)) + + +def _enum_factory(words): + def check(value, location, problems): + if not isinstance(value, str) or value not in words: + if isinstance(value, str) and (hit := _forbidden(value)): + problems.append((location, hit)) + else: + problems.append((location, "非法类型")) + return check + + +def _int_factory(minimum, *, none=False): + def check(value, location, problems): + if value is None and none: + return + if not _is_int(value) or value < minimum: + problems.append((location, "非法类型")) + return check + + +def _number_factory(*, none=False): + def check(value, location, problems): + if value is None and none: + return + if not _is_number(value): + problems.append((location, "非法类型")) + return check + + +def _none_or(check): + def wrapper(value, location, problems): + if value is not None: + check(value, location, problems) + return wrapper + + +def _p_token(value, location, problems) -> None: + _check_str(value, location, problems, _TOKEN_RE.fullmatch) + + +def _p_ts(value, location, problems) -> None: + _check_str(value, location, problems, _TIMESTAMP_RE.fullmatch) + + +def _p_ts_legacy(value, location, problems) -> None: + """旧记录的 started_at/ended_at:禁项必查,不按时间戳语法拒绝既有旧形态(评审 #51 G2 回归)。""" + _check_str(value, location, problems, lambda _text: True) + + +def _p_sha(value, location, problems) -> None: + _check_str(value, location, problems, _HEX64_RE.fullmatch) + + +def _p_path(value, location, problems) -> None: + _check_str(value, location, problems, _repo_relative) + + +def _p_signatures(value, location, problems) -> None: + """失败签名:检查名与哈希的短串,或派发固定的无空白问题描述。""" + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + _check_str(item, f"{location}[{index}]", problems, _NOSPACE_RE.fullmatch) + + +_p_size = _int_factory(0) + + +def _scan_struct(value, location, problems, fields: dict, *, exact_keys=False, required=()) -> None: + """结构化 dict:已知键按字段校验;未知键拒绝(在父位置报告,不回显键名);exact_keys 时缺键也拒绝。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key in (required or (fields if exact_keys else ())): + if key not in value: + problems.append((f"{location}.{key}", "缺字段")) + for key, item in value.items(): + check = fields.get(key) + if check is None: + problems.append((location, "未知字段")) + _scan_free(item, location, problems) + else: + check(item, f"{location}.{key}", problems) + + +def _scan_list(value, location, problems, fields, *, exact_keys=False, required=()) -> None: + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + _scan_struct(item, f"{location}[{index}]", problems, fields, exact_keys=exact_keys, required=required) + + +def _scan_free(value, location, problems) -> None: + """未知字段的任意 JSON 值:通用禁项逐键逐值生效;标量字符串再按自由文本值形态拒绝。""" + if isinstance(value, str): + if hit := _forbidden(value): + problems.append((location, hit)) + elif not _safe_value(value): + problems.append((location, "未知自由文本")) + elif isinstance(value, dict): + for key, item in value.items(): + hit = _forbidden(key, length=False) if isinstance(key, str) else None + if hit: + problems.append((location, hit)) + elif not isinstance(key, str) or not _KEY_RE.fullmatch(key): + problems.append((location, "键不合规")) + _scan_free(item, location, problems) + elif isinstance(value, list): + for index, item in enumerate(value): + _scan_free(item, f"{location}[{index}]", problems) + + +def _p_output_value(value, location, problems) -> None: + """outputs 标量值:数字/布尔/None 判型;字符串按摘要值形态,裸 shell 命令名也拒绝。""" + if value is None or isinstance(value, bool): + return + if isinstance(value, (int, float)): + if not math.isfinite(value): + problems.append((location, "非法类型")) + return + if not isinstance(value, str): + problems.append((location, "非法类型")) + return + if hit := _forbidden(value): + problems.append((location, hit)) + return + if _BARE_COMMAND_RE.fullmatch(value.strip()): + problems.append((location, "命令正文")) + return + if not _safe_value(value): + problems.append((location, "未知自由文本")) + + +def _p_outputs(value, location, problems) -> None: + """扁平 outputs:键是短标识,值是标量或安全形态字符串。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key, item in value.items(): + if not isinstance(key, str) or not _KEY_RE.fullmatch(key): + hit = _forbidden(key, length=False) if isinstance(key, str) else None + problems.append((location, hit or "键不合规")) + _scan_free(item, location, problems) + else: + _p_output_value(item, f"{location}.{key}", problems) + + +def _p_refs(value, location, problems) -> None: + """输入引用列表:kind/ref/sha256/size;kind=pattern 的 ref 允许规定通配模式。 + + 未知键的位置用固定占位(评审 #51 严重项):键名属记录内容,不得进入 Finding。 + """ + if not isinstance(value, list): + problems.append((location, "非法类型")) + return + for index, item in enumerate(value): + child = f"{location}[{index}]" + if not isinstance(item, dict): + problems.append((child, "非法类型")) + continue + for key, item_value in item.items(): + if key == "kind": + _p_token(item_value, f"{child}.kind", problems) + elif key == "ref": + is_pattern = item.get("kind") == "pattern" + _check_str(item_value, f"{child}.ref", problems, + _PATTERN_RE.fullmatch if is_pattern else _ref_form) + elif key == "sha256": + _p_sha(item_value, f"{child}.sha256", problems) + elif key == "size": + _p_size(item_value, f"{child}.size", problems) + else: + hidden = f"{child}.未知键(已隐去)" + problems.append((hidden, "未知字段")) + _scan_free(item_value, hidden, problems) + + +def _p_guard_denials(value, location, problems) -> None: + """守卫拒绝计数:键只有与内置规则理由完全相等的旧摘要可保留(不能含路径/换行),计数非负整数。""" + if not isinstance(value, dict): + problems.append((location, "非法类型")) + return + for key, count in value.items(): + hit = _forbidden(key, length=False) + if hit in ("换行", "本机路径"): + problems.append((location, hit)) + elif key not in _TRUSTED_GUARD_REASONS and not _CLEAN_X_DERIVED_RE.fullmatch(key): + problems.append((location, "未知理由")) + elif not _is_int(count) or count < 0: + problems.append((location, "非法类型")) + + +_DECISION_FIELDS = {"by": _none_or(_p_token), "rule": _none_or(_p_token), + "reason": _none_or(_enum_factory(_DECISION_REASONS))} +_ACTOR_FIELDS = {"role": _enum_factory(frozenset(events.ACTOR_ROLES)), "host": _p_token, "model": _p_token} +_STAGE_FIELDS = { + "stage": _enum_factory(frozenset(events.STAGES)), "step": _p_token, + "status": _enum_factory(frozenset(events.STATUSES) | {"prior"}), "ts": _p_ts, + "duration_ms": _int_factory(0, none=True), "attempt": _int_factory(1), "round": _int_factory(0), + # B77/T125:六键在收敛后的新记录不再写入(留在事件库),但旧记录(C3 十二键形状)仍合法—— + # 校验器保留、键可选,exact_keys 语义下缺席即通过、在场则按原口径校验。 + "inputs": _p_refs, "outputs": _p_outputs, "decision": _none_or( + lambda value, location, problems: _scan_struct(value, location, problems, _DECISION_FIELDS, + exact_keys=True)), + "actor": _none_or(lambda value, location, problems: _scan_struct(value, location, problems, + _ACTOR_FIELDS)), + "source": _p_token, "head_hash": _p_sha, +} +# status=prior 是更早 attempt 的 push_pr/ci_wait 指针行专用状态(run_timeline._POINTER_STATUS)。 +_ANCHOR_FIELDS = {"source": _p_token, "stage": _enum_factory(frozenset(events.STAGES)), + "head_hash": _p_sha, "fixed_in": _enum_factory(_FIXED_IN_VALUES)} +_MC_FIELDS = {"category": _enum_factory(_MC_CATEGORIES), "summary": _enum_factory(_MC_SUMMARIES), + "ref": _p_token} +_TOP_FIELDS = { + "task": _p_token, "class": _enum_factory(_K_CLASSES), "attempt": _int_factory(1), + "branch": _p_token, "trace_id": _p_token, + "gen_ai.agent.name": _p_token, "host_version": _p_token, "gen_ai.request.model": _p_token, + "prompt_sha256": _p_sha, "prompt_path": _p_path, "guard_ref": _p_token, + "started_at": _p_ts_legacy, "ended_at": _p_ts_legacy, "executor_seconds": _number_factory(), + "exit": _enum_factory(_EXIT_WORDS), "retries": _int_factory(0), + "failure_signatures": _p_signatures, "ci_rounds_before": _int_factory(0), + "guard_denials": _p_guard_denials, + "gen_ai.usage.input_tokens": _int_factory(0, none=True), + "gen_ai.usage.output_tokens": _int_factory(0, none=True), + "cost": _number_factory(none=True), "escalation": _none_or(_enum_factory(_EXIT_WORDS)), + "stages": lambda value, location, problems: _scan_list( + value, location, problems, _STAGE_FIELDS, + # B77/T125:必填七键(新形状);旧六键在场则校验、缺席合法(旧记录不受影响) + required=("stage", "step", "status", "ts", "duration_ms", "attempt", "round")), + "anchors": lambda value, location, problems: _scan_list(value, location, problems, _ANCHOR_FIELDS, + exact_keys=True), + "missing_context": lambda value, location, problems: _scan_list(value, location, problems, _MC_FIELDS, + required=("category", "summary")), + "missing_context_status": _enum_factory(_MC_STATUSES), + # B77/T125:fit_record 的截断标注(顶层可选,布尔/正整数) + "stages_truncated": lambda value, location, problems: problems.append((location, "布尔")) + if not (value is True or value is False) else None, + "stages_total": _int_factory(1), +} + + +def scan_record(record) -> list[tuple[str, str]]: + """记录全文递归扫描(B38):返回 (字段位置, 规则名);只写位置与规则名,不回显禁项。""" + problems: list[tuple[str, str]] = [] + if not isinstance(record, dict): + return [("记录", "非法类型")] + for key, value in record.items(): + check = _TOP_FIELDS.get(key) + if check is None: + problems.append(("记录", "未知字段")) + _scan_free(value, "记录", problems) + else: + check(value, str(key), problems) + return problems + + +def check_record_content(base: str, head: str, target: Scope, cwd: Path = ROOT) -> Finding | None: + """记录内容检查(B38):记录里有命令正文、本机路径或会话正文即失败的 Finding。 + + 由 check() 在 check_record 的 JSON/身份/提示词哈希核对之后调用;记录缺失、JSON 无效或未命中时 + 返回 None——干净记录的 Finding 与事件序列与之前逐字相同,坏记录才多一条失败 Finding。 + 判定只依据记录自身内容(scan_record),不读事件库。 + """ + records = _records(base, head, target, cwd) + if not records: + return None + _number, path = records[-1] + record, error = _load_record(head, path, cwd) + if error is not None or not isinstance(record, dict): + return None + problems = scan_record(record) + if not problems: + return None + shown = problems[:_MAX_PROBLEMS] + detail = ";".join(f"{location}:{rule}" for location, rule in shown) + if len(problems) > len(shown): + detail += f"(共 {len(problems)} 处)" + return Finding("记录内容", False, f"`{path}` 命中内容禁项:{detail}") + + def ci_rounds(runs: list[dict]) -> int: - """已完成的 build 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" + """已完成的 CI 轮次:按不同的 head 提交计(同一提交的重跑不算新一轮)。""" return len({run.get("headSha") or run.get("head_sha") for run in runs if run.get("status") == "completed" and (run.get("headSha") or run.get("head_sha"))}) @@ -139,30 +556,54 @@ def ci_rounds(runs: list[dict]) -> int: def check_ci(rounds: int | None, header: dict) -> Finding: limit = (header.get("budget") or {}).get("ci_rounds") if rounds is None: - return Finding("CI 轮次", False, "读不到该分支的 build 运行,按超预算处理") + return Finding("CI 轮次", False, "读不到该分支的 CI 运行,按超预算处理") if not isinstance(limit, int): return Finding("CI 轮次", False, "任务书没有 budget.ci_rounds") ok = rounds <= limit return Finding("CI 轮次", ok, f"已完成 {rounds} 轮,预算 {limit} 轮" + ("" if ok else ":超出预算")) +def _record(target: Scope | None, findings: list[Finding] | None) -> None: + """观察旁路:适用性与每个 Finding 一条事件(规则、通过与原因);失败不影响返回值。""" + try: + if target is None: + events.emit(stage="ci", step="run_check", status="skip", outputs={"applicable": False}) + return + events.emit(stage="ci", step="run_check", status="ok", + outputs={"applicable": True, "taskbook": target.taskbook or None, + "task": target.header.get("task") or None, "in_pr": target.in_pr}) + for finding in findings or []: + events.emit(stage="ci", step="run_check.finding", status="ok" if finding.ok else "fail", + outputs={"finding": finding.name, "ok": finding.ok}, + decision={"by": "run_check", "rule": finding.name, "reason": finding.reason}) + except Exception: # noqa: BLE001 设计要求:事件失败不得影响调用方 + return + + def check(base: str, head: str, branch: str, cwd: Path = ROOT, rounds: int | None = None, with_ci: bool = True) -> list[Finding] | None: """不适用(不是在实现任务书)时返回 None。""" target = scope(base, head, branch, cwd) if target is None: + _record(None, None) # 观察:不适用 return None task_id = target.header.get("task", "") findings = [check_trailers(base, head, task_id, cwd)] if not target.taskbook: findings.append(Finding("任务书", False, f"`Task: {task_id}` 找不到对应的任务书")) + _record(target, findings) return findings if target.in_pr: findings.append(Finding("运行记录", True, f"任务书 `{target.taskbook}` 随本 PR 提交:设计方自行实现,不要求运行记录")) + _record(target, findings) return findings findings.append(check_record(base, head, target, branch, cwd)) + content = check_record_content(base, head, target, cwd) + if content is not None: + findings.append(content) if with_ci: findings.append(check_ci(rounds, target.header)) + _record(target, findings) # 观察:适用性与各项发现 return findings @@ -182,7 +623,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--github", action="store_true", help="写 GITHUB_STEP_SUMMARY(只报告,不失败)") args = parser.parse_args(argv) branch = args.branch or os.environ.get("GITHUB_HEAD_REF") or git("rev-parse", "--abbrev-ref", args.head) - text = render(check(args.base, args.head, branch, with_ci=False)) + text = render(check(args.base, args.head, branch, cwd=ROOT, with_ci=False)) print(text) if args.github and os.environ.get("GITHUB_STEP_SUMMARY"): with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as handle: diff --git a/tests/test_harness_dispatch.py b/tests/test_harness_dispatch.py index 2d3c84f..b61bdef 100644 --- a/tests/test_harness_dispatch.py +++ b/tests/test_harness_dispatch.py @@ -21,9 +21,12 @@ from engine.agents import dispatch, dispatch_host GIT_ENV = { - "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com", - "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com", - "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", } TASKBOOK = """--- @@ -58,7 +61,7 @@ SPEC = "| 编号 | 内容 | 证据类型 | 覆盖 |\n|---|---|---|---|\n| DR14 | 合并 | 单测 | `test_mod` |\n" # 假执行方:按模式在槽位里做事。 -FAKE = textwrap.dedent(''' +FAKE = textwrap.dedent(""" import subprocess, sys, time from pathlib import Path mode = sys.argv[1] @@ -81,15 +84,15 @@ def commit(name, text): elif mode == "note": Path("build/dispatch").mkdir(parents=True, exist_ok=True) Path("build/dispatch/escalation.md").write_text("- 可选方案与推荐:A\\n- 需要决定的问题:Q") -''') +""") # 假 verify:有 bad*.txt 即以同样的理由失败。 -VERIFY = textwrap.dedent(''' +VERIFY = textwrap.dedent(""" import sys from pathlib import Path if list(Path(".").glob("bad*.txt")): print("FAIL: test_mod.Case 断言失败 1 != 2"); sys.exit(1) -''') +""") class FakeHost: @@ -113,13 +116,24 @@ def parse(self, events): class FakeGitHub: def __init__(self, claimed=(), ci=(True,)): self.claimed, self.ci = set(claimed), list(ci) - self.pushes, self.prs, self.comments, self.labels, self.issues = [], [], [], [], [] + self.pushes, self.prs, self.comments, self.labels, self.issues = ( + [], + [], + [], + [], + [], + ) def remote_branch_exists(self, branch): return branch in self.claimed def push(self, slot, branch): - subprocess.run(["git", "push", "-q", "-u", "origin", branch], cwd=slot, check=True, capture_output=True) + subprocess.run( + ["git", "push", "-q", "-u", "origin", branch], + cwd=slot, + check=True, + capture_output=True, + ) self.pushes.append(branch) def open_pr(self, slot, branch, title, body): @@ -135,7 +149,8 @@ def add_label(self, pr, label): def create_issue(self, title, body, labels): self.issues.append((title, body, labels)) - def wait_ci(self, branch, sha, timeout): + def wait_ci(self, branch, sha, timeout, detail=None): + # detail:引擎 B71 修复后 wait_ci 携带的升级通道附言(已推 head 等),夹具忽略其内容。 ok = self.ci.pop(0) if len(self.ci) > 1 else self.ci[0] return ok, "" if ok else "CI 未通过:test_mod.Case" @@ -148,9 +163,15 @@ def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, self.tmp, True) origin = self.tmp / "origin.git" - subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True) + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True + ) self.root = self.tmp / "repo" - subprocess.run(["git", "clone", "-q", str(origin), str(self.root)], check=True, capture_output=True) + subprocess.run( + ["git", "clone", "-q", str(origin), str(self.root)], + check=True, + capture_output=True, + ) self.write_task(ci_rounds=3) (self.root / "docs/specs").mkdir(parents=True) (self.root / "docs/specs/daily-report.md").write_text(SPEC) @@ -161,23 +182,40 @@ def setUp(self): self.git("commit", "-q", "-m", "base") self.git("push", "-q", "origin", "HEAD:main") self.git("fetch", "-q", "origin") - self.config = dispatch.Config(slots=2, slot_root=self.tmp / "slots", stall_seconds=30, poll_seconds=0.1, - verify=[sys.executable, "verify.py"]) + self.config = dispatch.Config( + slots=2, + slot_root=self.tmp / "slots", + stall_seconds=30, + poll_seconds=0.1, + verify=[sys.executable, "verify.py"], + ) (self.tmp / "slots").mkdir() - guard = mock.patch.object(dispatch, "prepare_guard", return_value=(Path("guard.ts"), "abc123")) + guard = mock.patch.object( + dispatch, "prepare_guard", return_value=(Path("guard.ts"), "abc123") + ) guard.start() self.addCleanup(guard.stop) def git(self, *args, cwd=None): - return subprocess.run(["git", *args], cwd=cwd or self.root, check=True, capture_output=True, text=True).stdout + return subprocess.run( + ["git", *args], + cwd=cwd or self.root, + check=True, + capture_output=True, + text=True, + ).stdout def write_task(self, ci_rounds): (self.root / "docs/plans").mkdir(parents=True, exist_ok=True) - (self.root / "docs/plans/task-005-new-test.md").write_text(TASKBOOK.format(ci_rounds=ci_rounds)) + (self.root / "docs/plans/task-005-new-test.md").write_text( + TASKBOOK.format(ci_rounds=ci_rounds) + ) def dispatcher(self, modes, github=None): host = FakeHost(self.root / "fake.py", modes) - return dispatch.Dispatcher(self.root, self.config, github or FakeGitHub(), host), host + return dispatch.Dispatcher( + self.root, self.config, github or FakeGitHub(), host + ), host def run_task(self, modes, github=None): runner, host = self.dispatcher(modes, github) @@ -188,24 +226,35 @@ def slot(self): return dispatch.slot_path(self.root, self.config, 1) def record(self, number=1): - return json.loads((self.slot() / f"docs/runs/task-005-new-test/{number}.json").read_text()) + return json.loads( + (self.slot() / f"docs/runs/task-005-new-test/{number}.json").read_text() + ) def test_success_writes_record_opens_pr_and_leaves_main_untouched(self): github = FakeGitHub() code, _, host = self.run_task(["commit"], github) self.assertEqual(code, 0) record = self.record() - self.assertEqual((record["task"], record["exit"], record["gen_ai.request.model"]), ("T005", "ok", "fake-model")) + self.assertEqual( + (record["task"], record["exit"], record["gen_ai.request.model"]), + ("T005", "ok", "fake-model"), + ) self.assertEqual(record["gen_ai.usage.input_tokens"], 10) self.assertEqual(record["guard_ref"], "abc123") self.assertTrue((self.slot() / record["prompt_path"]).exists()) self.assertEqual(len(github.prs), 1) self.assertIn("docs/runs/task-005-new-test/1.json", github.prs[0][2]) - self.assertEqual(github.pushes, ["task/005-new-test", "task/005-new-test"]) # 认领 + 结果 - self.assertIn("Task: T005", self.git("log", "-1", "--format=%B", cwd=self.slot())) + self.assertEqual( + github.pushes, ["task/005-new-test", "task/005-new-test"] + ) # 认领 + 结果 + self.assertIn( + "Task: T005", self.git("log", "-1", "--format=%B", cwd=self.slot()) + ) self.assertEqual(self.git("status", "--porcelain"), "") # 主目录不变 self.assertIn("docs/plans/task-005-new-test.md", host.prompts[0]) - self.assertFalse(list((dispatch.state_dir(self.root) / "slots").glob("*.json"))) # 槽位已归还 + self.assertFalse( + list((dispatch.state_dir(self.root) / "slots").glob("*.json")) + ) # 槽位已归还 def test_executor_gets_no_github_token(self): seen = {} @@ -215,8 +264,12 @@ def spy(argv, cwd, env, *args, **kwargs): seen.update(env) return original(argv, cwd, env, *args, **kwargs) - with mock.patch.dict(os.environ, {"GH_TOKEN": "secret-token", "GITHUB_TOKEN": "x"}), \ - mock.patch.object(dispatch_host, "run_monitored", spy): + with ( + mock.patch.dict( + os.environ, {"GH_TOKEN": "secret-token", "GITHUB_TOKEN": "x"} + ), + mock.patch.object(dispatch_host, "run_monitored", spy), + ): self.run_task(["commit"]) self.assertNotIn("GH_TOKEN", seen) self.assertNotIn("GITHUB_TOKEN", seen) @@ -225,7 +278,9 @@ def spy(argv, cwd, env, *args, **kwargs): self.assertEqual(seen["GIT_AUTHOR_NAME"], dispatch.agent_identity()[0]) def test_claimed_branch_stops_before_running(self): - runner, host = self.dispatcher(["commit"], FakeGitHub(claimed={"task/005-new-test"})) + runner, host = self.dispatcher( + ["commit"], FakeGitHub(claimed={"task/005-new-test"}) + ) with self.assertRaisesRegex(dispatch.Stop, "已被认领"): runner.run("docs/plans/task-005-new-test.md") self.assertEqual(host.prompts, []) @@ -239,7 +294,9 @@ def test_taskbook_not_on_main_is_refused(self): def test_exempt_history_taskbook_is_refused(self): (self.root / ".harness/state").mkdir(parents=True, exist_ok=True) - (self.root / ".harness/state/taskbook-exempt.txt").write_text("docs/plans/task-005-new-test.md # 历史\n") + (self.root / ".harness/state/taskbook-exempt.txt").write_text( + "docs/plans/task-005-new-test.md # 历史\n" + ) with self.assertRaisesRegex(dispatch.Stop, "豁免"): dispatch.admit("docs/plans/task-005-new-test.md", self.root) @@ -266,7 +323,9 @@ def test_same_failure_twice_is_a_loop(self): record = self.record() self.assertEqual(record["exit"], "loop") self.assertEqual(len(record["failure_signatures"]), 2) - self.assertEqual(record["failure_signatures"][0], record["failure_signatures"][1]) + self.assertEqual( + record["failure_signatures"][0], record["failure_signatures"][1] + ) self.assertIn("断言失败", host.prompts[1]) # 第二轮带上了失败摘要 self.assertIn("打转", runner.github.issues[0][1]) @@ -337,26 +396,51 @@ def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, self.tmp, True) origin = self.tmp / "origin.git" - subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True) + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True + ) self.root = self.tmp / "repo" - subprocess.run(["git", "clone", "-q", str(origin), str(self.root)], check=True, capture_output=True) + subprocess.run( + ["git", "clone", "-q", str(origin), str(self.root)], + check=True, + capture_output=True, + ) def publish(self, broken=False): - shutil.copytree(ROOT / ".harness", self.root / ".harness", ignore=shutil.ignore_patterns("__pycache__")) + shutil.copytree( + ROOT / ".harness", + self.root / ".harness", + ignore=shutil.ignore_patterns("__pycache__"), + ) shutil.copytree(ROOT / ".pi", self.root / ".pi") if broken: - (self.root / ".harness/engine/guards/command_guard.py").write_text("raise SystemExit(0)\n") - for args in (["add", "-A"], ["commit", "-q", "-m", "guard"], ["push", "-q", "origin", "HEAD:main"], - ["fetch", "-q", "origin"]): - subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True) + (self.root / ".harness/engine/guards/command_guard.py").write_text( + "raise SystemExit(0)\n" + ) + for args in ( + ["add", "-A"], + ["commit", "-q", "-m", "guard"], + ["push", "-q", "origin", "HEAD:main"], + ["fetch", "-q", "origin"], + ): + subprocess.run( + ["git", *args], cwd=self.root, check=True, capture_output=True + ) def test_guard_comes_from_origin_main(self): self.publish() - (self.root / ".harness/engine/guards/command_guard.py").write_text("raise SystemExit(0)\n") # 工作区被改:不影响 + (self.root / ".harness/engine/guards/command_guard.py").write_text( + "raise SystemExit(0)\n" + ) # 工作区被改:不影响 extension, ref = dispatch.prepare_guard(self.root) self.assertTrue(extension.exists()) self.assertIn(ref, str(extension)) - self.assertNotIn("SystemExit(0)", (extension.parents[2] / ".harness/engine/guards/command_guard.py").read_text()) + self.assertNotIn( + "SystemExit(0)", + ( + extension.parents[2] / ".harness/engine/guards/command_guard.py" + ).read_text(), + ) def test_broken_guard_on_main_stops_dispatch(self): self.publish(broken=True) @@ -373,19 +457,51 @@ def test_argv_ignores_project_files_and_loads_guard_explicitly(self): def test_parse_usage_model_and_guard_denials(self): events = [ - {"type": "message_end", "message": {"role": "assistant", "model": "glm", "usage": { - "input": 100, "cacheRead": 50, "output": 20, "cost": {"total": 0.01}}}}, - {"type": "tool_execution_end", "isError": True, "result": {"content": [{"type": "text", "text": - "harness 守卫拒绝了这次操作:\n - 执行者不能编辑判定器与护栏\n 如确需执行……"}]}}, - {"type": "message_end", "message": {"role": "assistant", "model": "glm", "usage": { - "input": 10, "output": 5, "cost": {"total": 0.002}}}}, + { + "type": "message_end", + "message": { + "role": "assistant", + "model": "glm", + "usage": { + "input": 100, + "cacheRead": 50, + "output": 20, + "cost": {"total": 0.01}, + }, + }, + }, + { + "type": "tool_execution_end", + "isError": True, + "result": { + "content": [ + { + "type": "text", + "text": "harness 守卫拒绝了这次操作:\n - 执行者不能编辑判定器与护栏\n 如确需执行……", + } + ] + }, + }, + { + "type": "message_end", + "message": { + "role": "assistant", + "model": "glm", + "usage": {"input": 10, "output": 5, "cost": {"total": 0.002}}, + }, + }, ] with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "events.jsonl" - path.write_text("\n".join(json.dumps(event, ensure_ascii=False) for event in events) + "\nnot json\n") + path.write_text( + "\n".join(json.dumps(event, ensure_ascii=False) for event in events) + + "\nnot json\n" + ) model, usage, denials = dispatch_host.PiHost().parse(path) self.assertEqual(model, "glm") - self.assertEqual(usage, {"input_tokens": 160, "output_tokens": 25, "cost": 0.012}) + self.assertEqual( + usage, {"input_tokens": 160, "output_tokens": 25, "cost": 0.012} + ) self.assertEqual(denials, {"执行者不能编辑判定器与护栏": 1}) diff --git a/tests/test_harness_review_independent.py b/tests/test_harness_review_independent.py index 7d9e55a..8ec0f29 100644 --- a/tests/test_harness_review_independent.py +++ b/tests/test_harness_review_independent.py @@ -22,14 +22,22 @@ class ParseTest(unittest.TestCase): def test_last_json_line_wins(self): - text = '评审意见……\n{"verdict": "通过", "findings": [], "summary": "旧"}\n更多\n' \ - '{"verdict": "不通过", "findings": [{"severity": "阻断", "location": "a.py:3", "problem": "p", "fix": "f"}], "summary": "s"}' + text = ( + '评审意见……\n{"verdict": "通过", "findings": [], "summary": "旧"}\n更多\n' + '{"verdict": "不通过", "findings": [{"severity": "阻断", "location": "a.py:3", "problem": "p", "fix": "f"}], "summary": "s"}' + ) verdict = review.parse_output(text) - self.assertEqual((verdict.verdict, verdict.summary, len(verdict.findings)), ("不通过", "s", 1)) + self.assertEqual( + (verdict.verdict, verdict.summary, len(verdict.findings)), + ("不通过", "s", 1), + ) self.assertTrue(verdict.flagged) def test_code_fence_and_unknown_verdict(self): - self.assertEqual(review.parse_output('```json {"verdict": "通过", "findings": []}').verdict, "通过") + self.assertEqual( + review.parse_output('```json {"verdict": "通过", "findings": []}').verdict, + "通过", + ) missing = review.parse_output('{"verdict": "好"}\n没有结论') self.assertEqual((missing.verdict, missing.parsed), ("需用户验收", False)) @@ -42,7 +50,10 @@ class SeparationTest(unittest.TestCase): def setUp(self): self.repo = TempRepo() self.addCleanup(self.repo.close) - self.repo.write("docs/plans/task-008-x.md", "---\ntask: T008\nclass: K4\ndesigner: codex\n---\n") + self.repo.write( + "docs/plans/task-008-x.md", + "---\ntask: T008\nclass: K4\ndesigner: codex\n---\n", + ) self.base = self.repo.commit("base") def commit(self, message): @@ -51,26 +62,42 @@ def commit(self, message): self.repo.git("commit", "-q", "-m", message) def test_taskbook_designer_decides(self): - self.commit("fix\n\nTask: T008\nCo-Authored-By: Claude Opus 5.5 ") - self.assertEqual(review.designer_of(self.base, "HEAD", "task/008-x", self.repo.path), "codex") + self.commit( + "fix\n\nTask: T008\nCo-Authored-By: Claude Opus 5.5 " + ) + self.assertEqual( + review.designer_of(self.base, "HEAD", "task/008-x", self.repo.path), "codex" + ) self.assertEqual(review.OTHER["codex"], "claude-code") def test_co_author_trailer_without_taskbook(self): - self.commit("harness\n\nCo-Authored-By: Claude Opus 5.5 ") - self.assertEqual(review.designer_of(self.base, "HEAD", "task/harness-x", self.repo.path), "claude-code") + self.commit( + "harness\n\nCo-Authored-By: Claude Opus 5.5 " + ) + self.assertEqual( + review.designer_of(self.base, "HEAD", "task/harness-x", self.repo.path), + "claude-code", + ) def test_unknown_designer(self): self.commit("plain") - self.assertIsNone(review.designer_of(self.base, "HEAD", "feature", self.repo.path)) + self.assertIsNone( + review.designer_of(self.base, "HEAD", "feature", self.repo.path) + ) class ReviewerTest(unittest.TestCase): def test_reviewers_are_read_only(self): codex = review.CodexReviewer("m").argv("p", Path("/w"), Path("/o")) self.assertEqual(codex[:4], ["codex", "exec", "-s", "read-only"]) - tuned = review.CodexReviewer("gpt-6-sol", "max").argv("p", Path("/w"), Path("/o")) + tuned = review.CodexReviewer("gpt-6-sol", "max").argv( + "p", Path("/w"), Path("/o") + ) self.assertIn('model_reasoning_effort="max"', tuned) - self.assertEqual(review.make_reviewer("codex").argv("p", Path("/w"), Path("/o"))[5], "gpt-6-sol") + self.assertEqual( + review.make_reviewer("codex").argv("p", Path("/w"), Path("/o"))[5], + "gpt-6-sol", + ) claude = review.ClaudeReviewer().argv("p", Path("/w"), Path("/o")) self.assertEqual(claude[claude.index("--allowedTools") + 1], "Read,Grep,Glob") @@ -79,83 +106,165 @@ class Fake(review.Reviewer): name = "fake" def argv(self, prompt, workspace, output): - code = ("import os; print('意见');" - "print('{\"verdict\": \"' + ('不通过' if os.environ.get('GH_TOKEN') else '通过') + '\", \"findings\": []}')") + code = ( + "import os; print('意见');" + "print('{\"verdict\": \"' + ('不通过' if os.environ.get('GH_TOKEN') else '通过') + '\", \"findings\": []}')" + ) return [sys.executable, "-c", code] def read(self, stdout, output): - return stdout, "fake-model" + return stdout, "fake-model", "test" - with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {"GH_TOKEN": "secret"}): + with ( + tempfile.TemporaryDirectory() as tmp, + mock.patch.dict(os.environ, {"GH_TOKEN": "secret"}), + ): verdict, model, _ = review.run_reviewer(Fake(), Path(tmp), 60) self.assertEqual((verdict.verdict, model), ("通过", "fake-model")) def test_pi_reviewer_is_read_only_and_parses_events(self): - argv = review.PiReviewer("zai-coding-cn/glm-5.3").argv("p", Path("/w"), Path("/o")) + argv = review.PiReviewer("zai-coding-cn/glm-5.3").argv( + "p", Path("/w"), Path("/o") + ) self.assertEqual(argv[:4], ["pi", "-na", "--tools", "read,grep,find,ls"]) self.assertEqual(argv[argv.index("--model") + 1], "zai-coding-cn/glm-5.3") events = [ - {"type": "message_end", "message": {"role": "assistant", "model": "glm-5.3", - "content": [{"type": "text", "text": "先看看"}]}}, - {"type": "message_end", "message": {"role": "toolResult", "content": [{"type": "text", "text": "x"}]}}, - {"type": "message_end", "message": {"role": "assistant", "model": "glm-5.3", "content": [ - {"type": "thinking", "thinking": "…"}, {"type": "text", "text": '意见\n{"verdict": "通过", "findings": []}'}]}}, + { + "type": "message_end", + "message": { + "role": "assistant", + "model": "glm-5.3", + "content": [{"type": "text", "text": "先看看"}], + }, + }, + { + "type": "message_end", + "message": { + "role": "toolResult", + "content": [{"type": "text", "text": "x"}], + }, + }, + { + "type": "message_end", + "message": { + "role": "assistant", + "model": "glm-5.3", + "content": [ + {"type": "thinking", "thinking": "…"}, + { + "type": "text", + "text": '意见\n{"verdict": "通过", "findings": []}', + }, + ], + }, + }, ] - text, model = review.PiReviewer().read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) - self.assertEqual((review.parse_output(text).verdict, model), ("通过", "glm-5.3")) - self.assertEqual(review.make_reviewer("pi").argv("p", Path("/w"), Path("/o"))[-2], "zai-coding-cn/glm-5.3") + text, model, _basis = review.PiReviewer().read( + "\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o") + ) + self.assertEqual( + (review.parse_output(text).verdict, model), ("通过", "glm-5.3") + ) + self.assertEqual( + review.make_reviewer("pi").argv("p", Path("/w"), Path("/o"))[-2], + "zai-coding-cn/glm-5.3", + ) def test_opencode_reviewer_is_read_only_and_parses_events(self): - argv = review.OpenCodeReviewer("zai-coding-plan/glm-5.3").argv("p", Path("/w"), Path("/o")) - self.assertEqual(argv[:6], ["opencode", "run", "--pure", "--agent", "plan", "--format"]) + argv = review.OpenCodeReviewer("zai-coding-plan/glm-5.3").argv( + "p", Path("/w"), Path("/o") + ) + self.assertEqual( + argv[:6], ["opencode", "run", "--pure", "--agent", "plan", "--format"] + ) events = [ {"type": "step_start", "part": {"type": "step-start"}}, {"type": "tool_use", "part": {"type": "tool", "tool": "read"}}, {"type": "text", "part": {"type": "text", "text": "意见"}}, - {"type": "text", "part": {"type": "text", "text": '```json\n{"verdict": "不通过", "findings": [{"severity": "严重"}]}\n```'}}, + { + "type": "text", + "part": { + "type": "text", + "text": '```json\n{"verdict": "不通过", "findings": [{"severity": "严重"}]}\n```', + }, + }, ] - text, model = review.OpenCodeReviewer("m").read("\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o")) + text, model, _basis = review.OpenCodeReviewer("m").read( + "\n".join(json.dumps(e, ensure_ascii=False) for e in events), Path("/o") + ) verdict = review.parse_output(text) - self.assertEqual((verdict.verdict, verdict.flagged, model), ("不通过", True, "m")) + self.assertEqual( + (verdict.verdict, verdict.flagged, model), ("不通过", True, "m") + ) self.assertIsInstance(review.make_reviewer("opencode"), review.OpenCodeReviewer) def test_opencode_tools_are_locked_to_read_only(self): """plan 代理本身不禁止 bash(2026-09-28 实测评审中运行了测试与 gh):配置里关掉命令与写入类工具。""" config = json.loads(review.OpenCodeReviewer.env["OPENCODE_CONFIG_CONTENT"]) self.assertEqual(config["permission"]["bash"], "deny") - self.assertFalse(any(config["tools"][name] for name in ("bash", "task", "webfetch", "write", "edit", "patch"))) + self.assertFalse( + any( + config["tools"][name] + for name in ("bash", "task", "webfetch", "write", "edit", "patch") + ) + ) def test_reviewer_stdin_is_closed(self): """opencode run 在标准输入是管道时会一直等(2026-09-28 实测):评审方的标准输入必须关闭。""" + class ReadsStdin(review.Reviewer): name = "stdin" def argv(self, prompt, workspace, output): - code = ("import sys; data = sys.stdin.read(); " - "print('{\"verdict\": \"通过\", \"findings\": []}' if data == '' else 'stdin')") + code = ( + "import sys; data = sys.stdin.read(); " + "print('{\"verdict\": \"通过\", \"findings\": []}' if data == '' else 'stdin')" + ) return [sys.executable, "-c", code] def read(self, stdout, output): - return stdout, "m" + return stdout, "m", "test" with tempfile.TemporaryDirectory() as tmp: verdict, _, _ = review.run_reviewer(ReadsStdin(), Path(tmp), 30) self.assertEqual(verdict.verdict, "通过") def test_claude_output_parsing(self): - stdout = json.dumps({"result": '好\n{"verdict": "通过", "findings": []}', "modelUsage": {"claude-x": {}}}) - text, model = review.ClaudeReviewer().read(stdout, Path("/none")) + stdout = json.dumps( + { + "result": '好\n{"verdict": "通过", "findings": []}', + "modelUsage": {"claude-x": {}}, + } + ) + text, model, _basis = review.ClaudeReviewer().read(stdout, Path("/none")) self.assertEqual(model, "claude-x") self.assertEqual(review.parse_output(text).verdict, "通过") class CommentAndScoreTest(unittest.TestCase): def test_comment_has_fixed_fields_and_hidden_data(self): - verdict = review.Verdict("不通过", [{"severity": "阻断", "location": "a.py:1", "problem": "漏传参数", "fix": "补上"}], "有阻断") - text = review.render_comment(verdict, "codex", "gpt-x", "claude-code", "abcdef1234567890", 90) + verdict = review.Verdict( + "不通过", + [ + { + "severity": "阻断", + "location": "a.py:1", + "problem": "漏传参数", + "fix": "补上", + } + ], + "有阻断", + ) + text = review.render_comment( + verdict, "codex", "gpt-x", "claude-code", "abcdef1234567890", 90 + ) self.assertNotIn("同为", text) - self.assertIn("评审方与本 PR 的执行方同为 pi", - review.render_comment(verdict, "pi", "glm-5.3", "claude-code", "abc", 1, same_host=True)) + self.assertIn( + "评审方与本 PR 的执行方同为 pi", + review.render_comment( + verdict, "pi", "glm-5.3", "claude-code", "abc", 1, same_host=True + ), + ) self.assertIn("### 独立评审(试行):不通过", text) self.assertIn("评审方:codex(gpt-x);设计方:claude-code", text) self.assertIn("| 阻断 | a.py:1 | 漏传参数 | 补上 |", text) @@ -165,12 +274,24 @@ def test_comment_has_fixed_fields_and_hidden_data(self): def test_score_excludes_errors_and_failed_reviews(self): """2026-09-28 实测:Codex 额度用尽后的失败曾被当成「放过」,会虚高 TNR。""" results = [ - {"kind": "bad", "flagged": True}, {"kind": "bad", "flagged": False}, - {"kind": "good", "flagged": False}, {"kind": "good", "flagged": False, "parsed": False}, + {"kind": "bad", "flagged": True}, + {"kind": "bad", "flagged": False}, + {"kind": "good", "flagged": False}, + {"kind": "good", "flagged": False, "parsed": False}, {"kind": "bad", "error": "注入点过期"}, ] - self.assertEqual(review.score(results), - {"bad": 2, "good": 1, "errors": 1, "tpr": 0.5, "tnr": 1.0, "unparsed": 1, "total": 5}) + self.assertEqual( + review.score(results), + { + "bad": 2, + "good": 1, + "errors": 1, + "tpr": 0.5, + "tnr": 1.0, + "unparsed": 1, + "total": 5, + }, + ) class FailureTest(unittest.TestCase): @@ -184,15 +305,21 @@ def __init__(self, outputs): def argv(self, prompt, workspace, output): code, text = self.outputs.pop(0) - return [sys.executable, "-c", f"import sys; print({text!r}); sys.exit({code})"] + return [ + sys.executable, + "-c", + f"import sys; print({text!r}); sys.exit({code})", + ] def read(self, stdout, output): - return stdout, "m" + return stdout, "m", "test" def test_error_exit_is_a_failure_not_a_verdict(self): quota = "ERROR: You've hit your usage limit. Try again at 12:51 AM." with tempfile.TemporaryDirectory() as tmp: - verdict, _, _ = review.run_reviewer(self.Scripted([(1, quota)]), Path(tmp), 60) + verdict, _, _ = review.run_reviewer( + self.Scripted([(1, quota)]), Path(tmp), 60 + ) self.assertEqual(verdict.verdict, "评审失败") self.assertIn("usage limit", verdict.failure) self.assertFalse(verdict.flagged) @@ -200,25 +327,52 @@ def test_error_exit_is_a_failure_not_a_verdict(self): def test_calibration_stops_after_three_failures_and_resumes(self): ok = (0, '{"verdict": "不通过", "findings": []}') fail = (1, "ERROR: usage limit") - samples = [{"kind": "bad", "id": f"S{n}", "title": "t", "file": "a", "find": "x", "replace": "y"} for n in range(6)] + samples = [ + { + "kind": "bad", + "id": f"S{n}", + "title": "t", + "file": "a", + "find": "x", + "replace": "y", + } + for n in range(6) + ] with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "result.json" - with mock.patch.object(review, "calibration_samples", return_value=samples), \ - mock.patch.object(review, "review_workspace", return_value=Path(tmp)), \ - mock.patch.object(review, "git", return_value="abc"), \ - mock.patch.object(review, "prepare_sample", return_value=("abc", "pr")), \ - mock.patch.object(review, "write_materials"), \ - mock.patch.object(review, "EVALS", Path(tmp)): + with ( + mock.patch.object(review, "calibration_samples", return_value=samples), + mock.patch.object(review, "review_workspace", return_value=Path(tmp)), + mock.patch.object(review, "git", return_value="abc"), + mock.patch.object(review, "prepare_sample", return_value=("abc", "pr")), + mock.patch.object(review, "write_materials"), + mock.patch.object(review, "EVALS", Path(tmp)), + ): out.write_text(json.dumps({"main": "abc", "date": "d", "samples": []})) - first = review.calibrate("codex", root=Path(tmp), resume=out, - reviewer=self.Scripted([ok, fail, fail, fail, ok, ok])) - self.assertEqual([item["id"] for item in first["samples"]], ["S0", "S1", "S2", "S3"]) - self.assertEqual((first["bad"], first["tpr"], first["unparsed"]), (1, 1.0, 3)) + first = review.calibrate( + "codex", + root=Path(tmp), + resume=out, + reviewer=self.Scripted([ok, fail, fail, fail, ok, ok]), + ) + self.assertEqual( + [item["id"] for item in first["samples"]], ["S0", "S1", "S2", "S3"] + ) + self.assertEqual( + (first["bad"], first["tpr"], first["unparsed"]), (1, 1.0, 3) + ) saved = json.loads(out.read_text()) self.assertEqual(len(saved["samples"]), 4) # 每个样本都已落盘 - second = review.calibrate("codex", root=Path(tmp), resume=out, - reviewer=self.Scripted([ok, ok, ok, ok, ok])) - self.assertEqual(sorted(item["id"] for item in second["samples"]), [f"S{n}" for n in range(6)]) + second = review.calibrate( + "codex", + root=Path(tmp), + resume=out, + reviewer=self.Scripted([ok, ok, ok, ok, ok]), + ) + self.assertEqual( + sorted(item["id"] for item in second["samples"]), + [f"S{n}" for n in range(6)], + ) self.assertEqual((second["bad"], second["unparsed"]), (6, 0)) @@ -237,8 +391,15 @@ def test_merged_pr_is_compared_against_its_merge_parent(self): merge = repo.git("rev-parse", "HEAD") repo.git("update-ref", "refs/remotes/origin/main", merge) repo.git("checkout", "-q", "--detach", head) - self.assertEqual(review.review_base({"state": "MERGED", "mergeCommit": {"oid": merge}}, repo.path), base) - self.assertEqual(review.review_base({"state": "OPEN"}, repo.path), head) # 已在 main 上:合并基点即 head + self.assertEqual( + review.review_base( + {"state": "MERGED", "mergeCommit": {"oid": merge}}, repo.path + ), + base, + ) + self.assertEqual( + review.review_base({"state": "OPEN"}, repo.path), head + ) # 已在 main 上:合并基点即 head class BackgroundTest(unittest.TestCase): @@ -264,10 +425,20 @@ def github(self): {"number": 1, "headRefOid": "h1", "comments": []}, {"number": 2, "headRefOid": "h2", "comments": []}, {"number": 3, "headRefOid": "h3", "comments": [{"body": "评审\n" + mark}]}, - {"number": 4, "headRefOid": "h4", "comments": [{"body": mark}]}, # 评过的是旧 head + { + "number": 4, + "headRefOid": "h4", + "comments": [{"body": mark}], + }, # 评过的是旧 head {"number": 5, "headRefOid": "h5", "comments": []}, ] - checks = {1: ["SUCCESS", "SKIPPED"], 2: ["SUCCESS", "FAILURE"], 3: ["SUCCESS"], 4: ["SUCCESS"], 5: None} + checks = { + 1: ["SUCCESS", "SKIPPED"], + 2: ["SUCCESS", "FAILURE"], + 3: ["SUCCESS"], + 4: ["SUCCESS"], + 5: None, + } return self.FakeGitHub(prs, checks) def test_pending_needs_green_ci_and_an_unreviewed_head(self): @@ -275,22 +446,37 @@ def test_pending_needs_green_ci_and_an_unreviewed_head(self): def test_review_pending_runs_one_at_a_time(self): seen = [] - done = review.review_pending(github=self.github(), review=lambda n, *rest: seen.append(n)) + done = review.review_pending( + github=self.github(), review=lambda n, *rest: seen.append(n) + ) self.assertEqual((done, seen), ([1, 4], [1, 4])) def test_watch_repeats_and_stops_on_stop_flag(self): repo = TempRepo() self.addCleanup(repo.close) seen, sleeps = [], [] - with mock.patch.object(review.dispatch, "state_dir", return_value=repo.path / "state"): - review.watch(1, github=self.github(), review=lambda n, *rest: seen.append(n), - sleep=sleeps.append, rounds=2, root=repo.path) + with mock.patch.object( + review.dispatch, "state_dir", return_value=repo.path / "state" + ): + review.watch( + 1, + github=self.github(), + review=lambda n, *rest: seen.append(n), + sleep=sleeps.append, + rounds=2, + root=repo.path, + ) self.assertEqual((seen, sleeps), ([1, 4, 1, 4], [60])) (repo.path / "state").mkdir() (repo.path / "state" / "stop").write_text("now") seen.clear() - review.watch(1, github=self.github(), review=lambda n, *rest: seen.append(n), sleep=sleeps.append, - root=repo.path) + review.watch( + 1, + github=self.github(), + review=lambda n, *rest: seen.append(n), + sleep=sleeps.append, + root=repo.path, + ) self.assertEqual(seen, []) @@ -303,7 +489,9 @@ def test_samples_cover_replay_cases_and_manifest(self): samples = review.calibration_samples() manifest = json.loads((ROOT / "evals/review/samples.json").read_text()) self.assertEqual(sum(s["kind"] == "bad" for s in samples), len(CASES)) - self.assertEqual([s["pr"] for s in samples if s["kind"] == "good"], manifest["good_prs"]) + self.assertEqual( + [s["pr"] for s in samples if s["kind"] == "good"], manifest["good_prs"] + ) self.assertGreaterEqual(len(samples), 40) def test_bad_sample_is_injected_without_touching_branches(self): @@ -311,12 +499,24 @@ def test_bad_sample_is_injected_without_touching_branches(self): self.addCleanup(repo.close) repo.write("scripts/a.py", "def f(x):\n return x > 0\n") base = repo.commit("base") - sample = {"kind": "bad", "id": "T-1:0", "title": "重构", "file": "scripts/a.py", - "find": "return x > 0", "replace": "return x >= 0"} + sample = { + "kind": "bad", + "id": "T-1:0", + "title": "重构", + "file": "scripts/a.py", + "find": "return x > 0", + "replace": "return x >= 0", + } got_base, text = review.prepare_sample(repo.path, sample, base, repo.path) self.assertEqual(got_base, base) self.assertIn("行为不变", text) - diff = subprocess.run(["git", "diff", f"{base}...HEAD"], cwd=repo.path, capture_output=True, text=True, check=True).stdout + diff = subprocess.run( + ["git", "diff", f"{base}...HEAD"], + cwd=repo.path, + capture_output=True, + text=True, + check=True, + ).stdout self.assertIn("+ return x >= 0", diff) self.assertEqual(repo.git("rev-parse", "main"), base) # 分支未动 stale = dict(sample, find="不存在的代码") @@ -330,7 +530,9 @@ def test_r2_and_above_are_marked_for_independent_review(self): job = workflow.split("\n request-review:\n", 1)[1].split("\n merge:\n", 1)[0] self.assertIn('if [ "$RISK" = "R2" ] || [ "$RISK" = "R3" ]; then', job) self.assertIn("--add-label needs-independent-review", job) - self.assertNotIn("--force", job.split("needs-independent-review")[0].split("R3")[-1]) + self.assertNotIn( + "--force", job.split("needs-independent-review")[0].split("R3")[-1] + ) if __name__ == "__main__":