agent-exec transport 92d779408cc72a3fdf9fdf6f005e9b5e/09f250401f29

This commit is contained in:
agent-exec-transport
2026-10-18 02:37:50 +00:00
parent b7f6a8bb9c
commit fb4c89d9dc
6 changed files with 323 additions and 4 deletions
+6
View File
@@ -186,6 +186,9 @@ jobs:
# 缺服务时失败而非跳过(make test-backend-pg 硬校验环境变量);
# 组件版本随 job 输出固定,供验收记录对齐(方案 KTD12/U2)。
test-pg:
# 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业);
# 进入 main 的 PR 与 main 推送跑全量门禁。
if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }}
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
@@ -479,6 +482,9 @@ jobs:
# identity 领养/verify、release 切换、伴生工作台、RDM 全轨部署。缺件即失败,
# 不静默跳过(SVC_TEST_REAL=1 硬开关)。
svc-realcombo:
# 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业);
# 进入 main 的 PR 与 main 推送跑全量门禁。
if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }}
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
+5 -1
View File
@@ -205,7 +205,11 @@ plugin-new:
@test -n "$(NAME)" || (echo "用法:make plugin-new NAME=<id>"; exit 2)
bash scripts/plugin-new.sh "$(NAME)"
.PHONY: test test-governance test-worktree contracts
.PHONY: test test-governance test-worktree test-affected contracts
# host 最小直接检查:按 BASE(缺省为卡片 immediate_target 或 origin/main)以来的改动选 make 目标;DRY=1 只打印。
test-affected:
python3 scripts/test_affected.py $(if $(BASE),--base $(BASE)) $(if $(DRY),--dry-run)
test-governance:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v
+5 -3
View File
@@ -6,7 +6,7 @@
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success、每个 --contexts 名称是某个 context 的子串,
- 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
@@ -30,6 +30,8 @@ EXIT_TIMEOUT = 2
EXIT_USAGE = 2
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
FAILING = ("failure", "error")
# skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。
DONE_OK = ("success", "skipped", "warning")
FATAL_HTTP = (400, 401, 403, 404, 410)
@@ -79,8 +81,8 @@ def describe(rows):
def green_key(rows, contexts):
"""满足全绿与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state != "success" for _, state, _ in rows):
"""满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state not in DONE_OK for _, state, _ in rows):
return None
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
return None
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。
用法:test_affected.py [--base REF] [--dry-run]
base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手),
再否则 origin/main;裸分支名在 origin/<name> 存在时解析为 origin/<name>。
改动路径 = `git diff <merge-base>...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标:
scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance
modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd;
deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins;
modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。
选中任一目标时先跑 test-worktree;单次 `make <targets>` 执行,退出码随 make。
退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。
"""
import argparse
import os
import subprocess
import sys
DEFAULT_BASE = "origin/main"
EXIT_USAGE = 2
GITLINK_MODE = "160000"
ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core",
"test-backend", "test-frontend", "test-ui", "test-agentcmd")
GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/")
GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md")
COMPONENTS = {
"modules/core": "test-core",
"modules/backend": "test-backend",
"modules/frontend": "test-frontend",
"modules/ui": "test-ui",
"deps/agent-command": "test-agentcmd",
"deps/agent-intent": "test-libs",
"deps/agent-native": "test-libs",
"deps/agent-squad": "test-libs",
}
class Stop(Exception):
pass
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
if proc.returncode != 0:
raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}")
return proc.stdout
def ref_exists(ref):
return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
capture_output=True).returncode == 0
def card_target():
"""当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。"""
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import task_pr_check
meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main")
except Exception:
return None
return meta.get("immediate_target") if text is not None else None
def resolve_base(requested):
name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE
if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"):
name = f"origin/{name}"
if not ref_exists(name):
raise Stop(f"base {name} does not resolve to a commit")
return name
def raw_changes(*diff_args):
"""{path: is_gitlink},取自 `git diff --raw -z --no-renames`。"""
out = git("diff", "--raw", "-z", "--no-renames", *diff_args)
fields = out.split("\0")
changes = {}
for meta, path in zip(fields[0::2], fields[1::2]):
modes = meta.lstrip(":").split()[:2]
changes[path] = changes.get(path, False) or GITLINK_MODE in modes
return changes
def changed_paths(base):
merge_base = git("merge-base", base, "HEAD").strip()
changes = raw_changes(merge_base, "HEAD")
for path, gitlink in raw_changes("HEAD").items():
changes[path] = changes.get(path, False) or gitlink
return changes
def targets_for(path, gitlink):
found = set()
if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES:
found.add("test-governance")
for prefix, target in COMPONENTS.items():
if path == prefix or path.startswith(prefix + "/"):
found.add(target)
if gitlink or path == ".gitmodules":
found.add("test-pins")
if path.startswith("modules/") or path == "scripts/lint-boundaries.sh":
found.add("test-boundaries")
return found
def select(changes):
found = set()
for path, gitlink in changes.items():
found |= targets_for(path, gitlink)
return ["test-worktree", *(t for t in ORDER if t in found)] if found else []
def main(argv=None):
parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查")
parser.add_argument("--base")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
try:
os.chdir(git("rev-parse", "--show-toplevel").strip())
base = resolve_base(args.base)
changes = changed_paths(base)
except Stop as stop:
print(f"[affected] ERROR {stop}")
return EXIT_USAGE
targets = select(changes)
print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True)
if not targets:
print("[affected] nothing to test")
return 0
if args.dry_run:
print(f"[affected] dry-run: make {' '.join(targets)}")
return 0
return subprocess.run(["make", *targets]).returncode
if __name__ == "__main__":
sys.exit(main())
+16
View File
@@ -177,6 +177,22 @@ class CiWaitTests(unittest.TestCase):
self.assertIn("missing contexts: e2e", out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_skipped_and_warning_contexts_count_as_done_not_failed(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")],
[status("ci/test", "success"), status("ci/test-pg", "skipped"),
status("ci/svc-realcombo", "warning")]]
code, out, _ = self.run_wait("--contexts", "test-pg", "--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 3, out)
self.assertNotIn("FAIL", out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
def test_skipped_does_not_mask_a_pending_context(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")]]
code, out, _ = self.run_wait("--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_context_names_match_as_substrings(self):
self.gitea.script = [[status("CI / test (pull_request)", "success")]]
code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60")
+150
View File
@@ -0,0 +1,150 @@
"""按改动选检查(scripts/test_affected.py):临时 Git 仓库 + 本地裸 origin,全部离线。"""
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "test_affected.py"
class TestAffectedTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="test-affected-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.repo = root / "repo"
self.env = {k: v for k, v in os.environ.items()
if not k.startswith(("GIT_", "MAKE")) and k not in {"BASE", "TASK_TARGET_BRANCH"}}
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_TERMINAL_PROMPT": "0", "HOME": str(root / "no-home"),
"GIT_AUTHOR_NAME": "Affected Test", "GIT_AUTHOR_EMAIL": "affected@example.invalid",
"GIT_COMMITTER_NAME": "Affected Test",
"GIT_COMMITTER_EMAIL": "affected@example.invalid"})
self.git(root, "init", "-q", "--bare", "--initial-branch=main", str(root / "origin.git"))
self.git(root, "init", "-q", "--initial-branch=main", str(self.repo))
self.git(self.repo, "remote", "add", "origin", str(root / "origin.git"))
self.write("README.md", "fixture\n")
self.write("modules/ui/notes.txt", "ui\n")
self.commit("Create fixture")
self.git(self.repo, "push", "-q", "origin", "main")
self.git(self.repo, "checkout", "-q", "-b", "feature/work")
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True,
check=True).stdout.strip()
def write(self, rel, text):
path = self.repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def commit(self, message, gitlinks=()):
self.git(self.repo, "add", "-A")
sha = self.git(self.repo, "rev-parse", "HEAD") if gitlinks else None
for path in gitlinks:
self.git(self.repo, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}")
self.git(self.repo, "commit", "-q", "-m", message)
def run_affected(self, *args, base=None):
env = self.env | ({"BASE": base} if base is not None else {})
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=env,
capture_output=True, text=True, timeout=60)
return result.returncode, result.stdout + result.stderr
def summary(self, out):
lines = [line for line in out.splitlines() if line.startswith("[affected] base=")]
self.assertEqual(len(lines), 1, out)
return dict(part.split("=", 1) for part in lines[0].split()[1:])
def test_mapping_dry_run_orders_and_dedupes_targets(self):
self.write("scripts/tool.py", "print('x')\n")
self.write("tests/test_tool.py", "\n")
self.write("modules/core/a.go", "package a\n")
self.write("deps/agent-native/x.go", "package x\n")
self.write("deps/agent-squad/y.go", "package y\n")
self.write("docs/guide.md", "doc\n")
self.write(".task/card.md", "card\n")
self.commit("Branch work", gitlinks=["deps/agent-command"])
self.write("modules/ui/notes.txt", "uncommitted tracked change\n")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual(info["base"], "origin/main")
self.assertEqual(info["changed"], "9", out)
self.assertEqual(info["targets"], "test-worktree,test-governance,test-boundaries,test-pins,test-libs,"
"test-core,test-ui,test-agentcmd", out)
self.assertIn("[affected] dry-run: make test-worktree test-governance", out)
def test_gitlink_and_gitmodules_select_pins_and_module_target(self):
self.write(".gitmodules", "[submodule \"modules/backend\"]\n\tpath = modules/backend\n")
self.commit("Bump backend", gitlinks=["modules/backend"])
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["targets"],
"test-worktree,test-boundaries,test-pins,test-backend", out)
def test_lint_boundaries_and_governance_docs_select_targets(self):
self.write("scripts/lint-boundaries.sh", "#!/bin/sh\n")
self.write("AGENT-EXECUTION.md", "x\n")
self.commit("Lint")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["targets"], "test-worktree,test-governance,test-boundaries", out)
def test_docs_only_change_runs_nothing(self):
self.write("docs/guide.md", "doc\n")
self.write(".task/card.md", "card\n")
self.write("NOTES.md", "notes\n")
self.commit("Docs only")
self.write("README.md", "edited\n")
code, out = self.run_affected()
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual((info["changed"], info["targets"]), ("4", "none"), out)
self.assertIn("[affected] nothing to test", out)
self.assertNotIn("dry-run", out)
def test_base_env_resolves_bare_branch_to_origin(self):
self.write("scripts/tool.py", "x\n")
self.commit("Tool")
code, out = self.run_affected("--dry-run", base="main")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["base"], "origin/main")
def test_base_env_local_branch_limits_changes_since_that_base(self):
self.write("modules/core/a.go", "package a\n")
self.commit("Core")
self.git(self.repo, "branch", "local-base")
self.write("docs/later.md", "later\n")
self.commit("Later docs")
code, out = self.run_affected("--dry-run", base="local-base")
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual((info["base"], info["changed"], info["targets"]), ("local-base", "1", "none"), out)
code, out = self.run_affected("--dry-run", "--base", "origin/main", base="local-base")
self.assertEqual(self.summary(out)["base"], "origin/main", out)
def test_unresolvable_base_is_a_usage_error(self):
code, out = self.run_affected("--dry-run", base="no-such-branch")
self.assertEqual(code, 2, out)
self.assertIn("[affected] ERROR base no-such-branch does not resolve", out)
def test_default_base_is_card_immediate_target(self):
self.git(self.repo, "push", "-q", "origin", "main:feature/init")
self.git(self.repo, "fetch", "-q", "origin")
self.write(".task/child.md", "# child\n- 工作区:/tmp/x;分支:feature/work\n"
"- 分支类型:child;initiative_id:init;root_target:main;immediate_target:feature/init\n")
self.commit("Card")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["base"], "origin/feature/init", out)
self.git(self.repo, "checkout", "-q", "--detach")
code, out = self.run_affected("--dry-run")
self.assertEqual(self.summary(out)["base"], "origin/main", out)
if __name__ == "__main__":
unittest.main()