agent-exec transport e4ff44791a4210144c6165ceadebb342/692536c1c7d0

This commit is contained in:
agent-exec-transport
2026-10-18 02:29:25 +00:00
parent 2757b6f68e
commit b7f6a8bb9c
8 changed files with 430 additions and 7 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ def materialize(repo):
for child, expected in submodules(repo):
check_children(repo, "before-update")
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
"--checkout", "--", str(child.relative_to(repo))])
"--jobs", "8", "--checkout", "--", str(child.relative_to(repo))])
if result.returncode:
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
materialize(child)
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。
用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]]
--sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
截止前从未成功轮询才以 6 放弃)。
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<sha>`。
退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import re
import subprocess
import sys
import time
import urllib.parse
from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug
EXIT_FAILED = 1
EXIT_TIMEOUT = 2
EXIT_USAGE = 2
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
FAILING = ("failure", "error")
FATAL_HTTP = (400, 401, 403, 404, 410)
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
return proc.stdout.strip() if proc.returncode == 0 else None
def resolve_sha(sha):
value = (sha or "HEAD").strip()
if not SHA_RE.match(value.lower()):
value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or ""
value = value.lower()
if not SHA_RE.match(value):
raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA")
return value
def interval_arg(text):
value = float(text)
if value < 5:
raise argparse.ArgumentTypeError("interval must be at least 5 seconds")
return value
def timeout_arg(text):
value = float(text)
if value <= 0:
raise argparse.ArgumentTypeError("timeout must be positive")
return value
def observe(data):
"""[(context, state, target_url)],按 context 排序;忽略非 dict 项。"""
items = data.get("statuses") if isinstance(data, dict) else None
rows = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown"
rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or "")))
return sorted(rows)
def describe(rows):
return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)"
def green_key(rows, contexts):
"""满足全绿与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state != "success" for _, state, _ in rows):
return None
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
return None
return tuple((ctx, state) for ctx, state, _ in rows)
def wait(sha, interval, timeout, contexts, sleep, clock):
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)")
origin = git("remote", "get-url", "origin")
if not origin:
raise Stop(EXIT_REMOTE, "cannot read origin remote URL")
owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/"))
url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status"
deadline = clock() + timeout
rows, shown, previous_green, polled, last_error = [], None, None, False, None
while True:
try:
data = http_get_json(url, token)
except Stop as stop:
if stop.status in FATAL_HTTP:
raise
last_error = stop.message
else:
polled = True
rows = observe(data)
line = describe(rows)
if line != shown:
print(f"[ci-wait] sha={sha[:12]} {line}", flush=True)
shown = line
failing = [row for row in rows if row[1] in FAILING]
if failing:
for ctx, _, target in failing:
print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}")
print(f"[verdict] CI_FAILED sha={sha}")
return EXIT_FAILED
key = green_key(rows, contexts)
if key is not None and key == previous_green:
print(f"[verdict] CI_SUCCESS sha={sha}")
return 0
previous_green = key
remaining = deadline - clock()
if remaining <= 0:
break
sleep(min(interval, remaining))
if not polled:
raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline")
print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}")
missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)]
if missing:
print(f"[ci-wait] missing contexts: {', '.join(missing)}")
print(f"[verdict] CI_TIMEOUT sha={sha}")
return EXIT_TIMEOUT
def main(argv=None, sleep=time.sleep, clock=time.monotonic):
parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论")
parser.add_argument("--sha")
parser.add_argument("--interval", type=interval_arg, default=15.0)
parser.add_argument("--timeout", type=timeout_arg, default=1200.0)
parser.add_argument("--contexts", default="")
args = parser.parse_args(argv)
contexts = [name.strip() for name in args.contexts.split(",") if name.strip()]
try:
sha = resolve_sha(args.sha)
return wait(sha, args.interval, args.timeout, contexts, sleep, clock)
except Stop as stop:
print(f"[ci-wait] ERROR {stop.message}")
if stop.code == EXIT_REMOTE:
print("[verdict] REMOTE_UNVERIFIED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -230,7 +230,7 @@ cmd_start() {
git worktree add -q "$wt" -b "$name" "$start_ref"
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
(cd "$wt" && git submodule update --init --recursive --jobs 8 >/dev/null 2>&1) \
|| echo "[warn] submodule update 非零退出,见下方状态核对"
local sub_drift
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
@@ -956,7 +956,7 @@ cmd_finish() {
return 1
fi
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
echo "[next] inspect task-card closure with make task-close in the main checkout when authorized"
echo "[next] cards are normally fold-closed before merge with make task-close SELF=1; only legacy cards reported by precheck [cards] need make task-close in the main checkout when authorized"
}
case "${1:-}" in
+3 -2
View File
@@ -51,10 +51,11 @@ EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message):
def __init__(self, code, message, status=None):
super().__init__(message)
self.code = code
self.message = message
self.status = status
def utc_now():
@@ -152,7 +153,7 @@ def http_get_json(url, token):
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}")
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code)
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
try: