Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .harness/engine.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"engine": "delivery-harness",
"version": "0.1.0",
"commit": "d253875b40fd2907f25732890727a092e63febcc",
"tree": "sha256:1ed5c4fd917aaf55c23baa290bd5fa3d24ee9ac57929f1f12d8d067e2c5d7f49"
"commit": "a1f73548fd6b61dcac7fe670233b152c3cd1c88a",
"tree": "sha256:6f39a109a94ae624c5c9cfbcc20f215bc8829069079d2f1237c768cb39eec8a6"
}
227 changes: 122 additions & 105 deletions .harness/engine/agents/dispatch.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .harness/engine/agents/dispatch_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class RunResult:
# 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 # 守卫放行的工具调用数(含普通失败;被守卫拒绝的调用不计入)


Expand Down
5 changes: 4 additions & 1 deletion .harness/engine/agents/dispatch_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,15 @@ def review_materials(workspace: Path, base: str, head: str, taskbook_rel: str |
"""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", folder / "diff.patch", f"{base}...{head}", {"id": "diff_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),
Expand Down
149 changes: 149 additions & 0 deletions .harness/engine/agents/github.py
Original file line number Diff line number Diff line change
@@ -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)} 分钟"
160 changes: 160 additions & 0 deletions .harness/engine/agents/plan_review.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading