Files
codex-app-server/cloud/wait_ops.py
T
host f39ed3654e ci3: lint/mypy 第二阶段清理 + 覆盖率 76% -> 78%(门槛不动)
基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上),
基线 origin/main aebc6238;本轨不改 #251 已定语义、不动
workflow 门槛、不删检查、不加 skip。

lint(ruff E,F@120):109 -> 10
- F401 43 -> 0:删未用导入;try/except 双绑定的名字出现
  “打地鼠”(删 except 分支后 try 分支又被标出),两分支
  均已核对正文无引用后清掉;MCP 三模块的 Error 名不删,
  改走 importlib 属性绑定(见下),两路径绑定都保留。
- E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行,
  tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号);
  一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。
- E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server
  3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值
  完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host
  5 项断开 git commit 参数表。
- E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app
  _base64 移到顶部,之前无引用、无重名);剩余 10 项全是
  tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件
  沿用同一既定模式),移上去会破坏单文件直跑,有意保留。
- F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、
  tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖;
  host dispatch_from_branch 新增真实 git 仓库回归测试。
- F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get
  逐行相同,删本地定义,全部调用点解析到同一 import;
  新增 test_server_row_get(同一对象 + 三路行为)。

mypy(生产范围):24 -> 0,零新增 type: ignore
- no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import
  改 importlib.import_module + 属性绑定(cloud 优先、flat
  copy 回落、内层 ImportError 同样回落,语义与原来一致;
  flat-copy 子进程测试证实回落后 __module__ 为顶层名);
  server _row_get 随 F811 消除。
- arg-type 10 -> 0:harness2_tasks shared 改 TypedDict
  逐键精确类型;CodexProbeAdapter 补 no-op validate_tree
  (_adapter_validate 本就跳过缺失方法,行为一致)。
- var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、
  verify-multiuser RESULTS 加精确注解。
- assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明
  Any 再 try import(草稿先验 mypy 通过)。

bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改
(属第 3 项白名单轨范围),只做 triage:
- 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell
  语义(新增 test_host_run_verify 断言管道与 && 真实生效)。
- 1497 notify:模板来自算子配置,三处插值全部 shlex.quote,
  证据为既有 test_notify_failure_quotes_shell_metacharacters
  (; touch 与 $(touch ) 不执行)。
- 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。
- 无新增 High;pip-audit 0 漏洞。

覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87%
(22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%)
- client.py 0% -> 90%(pipe 假子进程 + main 分支)
- run_parallel.py 0% -> 97%、migrate 包装 0% -> 95%
- playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、
  websearch_mcp 51% -> 86%、deploy.py 56% -> 77%
- verify-multiuser 0% -> 27%(仅 check/req helper;main()
  是主机侧真实多用户实弹验证,按门禁不建生产任务、
  不以 mock 冒充,仍为缺口)
- host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行
  的计量口径变化,非代码膨胀)
- 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径;
  缺口主力仍是 server.py / worker.py / host.py 大文件,
  下一阶段须针对性深测,非本轨快赢可及。

验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests
1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0;
bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删,
提交后工作树干净。
2026-11-03 02:48:54 +00:00

707 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""线程等待与移交操作(对照 codex app 的 8 项工具面)。
本模块只做控制面纯逻辑,不跑模型、不联网、不 import cloud 其它
模块(与 automations / peer_messages 同款,避免循环 import);
server.py 只负责鉴权、路由与错误码映射。
8 项在控制面的落点(详见 docs/thread-wait-ops.md 对照表):
- read_thread:server 用任务行 + 链路快照 + 尽力 thread-read
合成摘要,本模块不重复造 thread 解析。
- get_handoff_status:task_handoffs 表(本模块)。移交本身
不是本模块发明的传输通道:记录只登记“某任务被交到某主机”
的意图与对方回报的进度;真正的执行仍在目标主机的 worker
上,状态靠 report 回写,绝不凭登记就谎称已交接。
- get_worktree_creation_status:派生状态(本模块
worktree_status)。云端任务的 worktree 由 worker 在认领后
同步建,没有独立的异步创建任务可轮询——本函数如实报告
目录存在与否、任务状态与环境状态,不编造百分比进度。
- read_pull_request_check_diagnostics:本模块只解析
`gh pr checks` 的输出;日志原文 gh 本身不给,失败原因
即未通过的 check 名单,见 parse_pr_checks。
- automation_update:cloud/automations.py 的 update_automation
(本批新增),本模块不另起一套自动化定义。
- send_message_to_thread:server 复用 followup 投递(queued /
direct),本模块不另建队列。
- clock.sleep:interruptible_sleep(步进睡眠,可被 stop_event
打断;server 的 /sleep 端点与 host.py 的 sleep 命令共用它)。
- wait_threads:poll_task_statuses(轮询回调取状态,直到
全部终态或超时;server 的 /wait 端点用 DB 回读作回调)。
表迁移:task_handoffs 是 SQLite 懒建表(与 pr_jobs /
automations 同款),不在 POSTGRES_SCHEMA 里,因此必须同步
进 cloud/migrations.py 的 UNMIGRATED_TABLES(test_p2_migrations
会实测“源码里真有 DDL 且不在 PG schema 里”)。
"""
import base64
import json
import re
import time
import uuid
HANDOFF_STATUSES = ("pending", "accepted", "running",
"completed", "failed")
# report 允许的状态转移:终态(completed / failed)不可再动;
# pending 可进 accepted / running / 终态,accepted 可进 running /
# 终态,running 只能进终态。跳过中间态不算错(对方可能直接报
# completed),但终态回退、终态重报一律拒绝。
_HANDOFF_TRANSITIONS = {
"pending": ("accepted", "running", "completed", "failed"),
"accepted": ("running", "completed", "failed"),
"running": ("completed", "failed"),
"completed": (),
"failed": (),
}
HANDOFF_COLUMNS = (
"id", "task_id", "project_id", "source_host", "target_host",
"status", "progress", "detail", "created_by", "created_at",
"updated_at")
TERMINAL_TASK_STATUSES = frozenset(
{"completed", "failed", "interrupted", "merged", "merge-failed"})
MAX_SLEEP_SECONDS = 60
MAX_WAIT_SECONDS = 120
WAIT_POLL_SECONDS = 0.5
MAX_WAIT_THREADS = 8
# wait_threads 的重要变化只认这几类:终态、待审批、待用户输入、
# 待继续处理。running / queued 之间的流转、链路条目增加都
# 不算——否则一次等待会被每条进度刷屏,协调方永远等不到
# “第一个需要我处理”的线程。状态映射见 detect_significant_change。
SIGNIFICANT_KINDS = ("terminal", "needs_approval", "needs_input",
"needs_continue")
def row_get(row, name):
"""行取值容错:sqlite3.Row 缺列报 IndexError、dict 缺键报
KeyError、空行报 TypeError,一律回 None(与 automations
的 row_get 同款,老库行缺列时读移交列表不能因此炸掉)。"""
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
def _row_dict(row, columns):
if row is None or isinstance(row, dict):
return row
try:
row["id"]
return row
except TypeError:
return dict(zip(columns, row))
def ensure_handoffs_table(conn):
"""幂等建 task_handoffs 表。调用方在写成功后 commit
(server 的 handler 与 peer 模式同款:建表后由业务写
操作一并 commit;只读的 GET 也调它建表再读,空库回空)。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS task_handoffs("
"id TEXT PRIMARY KEY, task_id TEXT NOT NULL, "
"project_id TEXT NOT NULL, source_host TEXT, "
"target_host TEXT NOT NULL, status TEXT NOT NULL, "
"progress INTEGER NOT NULL DEFAULT 0, detail TEXT, "
"created_by TEXT, created_at REAL, updated_at REAL)"
)
def handoff_to_dict(row, now=None):
"""行 -> 对外 dict;progress 一律钳到 0-100,读侧也钳,
防止旧脏行把进度写成 120 或 -1 后原样漏到客户端。"""
r = _row_dict(row, HANDOFF_COLUMNS)
progress = row_get(r, "progress")
try:
progress = int(progress)
except (TypeError, ValueError):
progress = 0
progress = max(0, min(100, progress))
return {
"id": row_get(r, "id"),
"task_id": row_get(r, "task_id"),
"project_id": row_get(r, "project_id"),
"source_host": row_get(r, "source_host"),
"target_host": row_get(r, "target_host"),
"status": row_get(r, "status"),
"progress": progress,
"detail": row_get(r, "detail") or "",
"created_by": row_get(r, "created_by"),
"created_at": row_get(r, "created_at"),
"updated_at": row_get(r, "updated_at"),
}
def create_handoff(conn, task_id, project_id, target_host, created_by,
source_host=None, detail="", now=None):
"""登记一次移交:同一任务同时只能有一个未终态移交
(pending / accepted / running),否则抛 ValueError——
两个进行中的移交指向不同主机时,轮询方分不清该信哪一个。
成功时 commit 并返回 dict;校验失败抛 ValueError。"""
if not target_host or not isinstance(target_host, str) \
or not target_host.strip():
raise ValueError("target_host required")
target_host = target_host.strip()
if len(target_host) > 200:
raise ValueError("target_host too long")
if detail is not None and not isinstance(detail, str):
raise ValueError("detail must be a string")
detail = (detail or "").strip()
if len(detail) > 2000:
raise ValueError("detail too long")
if now is None:
now = time.time()
ensure_handoffs_table(conn)
existing = conn.execute(
"SELECT id FROM task_handoffs WHERE task_id = ?"
" AND status IN ('pending', 'accepted', 'running')"
" LIMIT 1",
(task_id,)).fetchone()
if existing is not None:
raise ValueError("task already has an open handoff")
handoff_id = uuid.uuid4().hex
conn.execute(
"INSERT INTO task_handoffs(id, task_id, project_id,"
" source_host, target_host, status, progress, detail,"
" created_by, created_at, updated_at)"
" VALUES(?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
(handoff_id, task_id, project_id, source_host, target_host,
detail, created_by, now, now))
conn.commit()
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
return handoff_to_dict(row)
def get_handoff(conn, handoff_id):
"""按 id 取移交(跨项目时调用方自己比对 project_id 后
回 404,不在本函数里泄露跨项目存在性)。"""
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
if row is None:
return None
return handoff_to_dict(row)
def latest_handoff(conn, task_id):
"""某任务最新的一次移交(按 created_at、id 排序,时间
戳相同时 id 兜底,保证同一库两次读到同一条“最新”)。"""
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE task_id = ?"
" ORDER BY created_at DESC, id DESC LIMIT 1",
(task_id,)).fetchone()
if row is None:
return None
return handoff_to_dict(row)
def report_handoff(conn, handoff_id, status, progress=None, detail=None,
now=None):
"""对方主机回报进度:返回 (ok, handoff_or_error)。
ok 为 False 时第二个值是错误文本(调用方按 400 / 409
映射:非法状态与非法进度是 400,非法转移是 409)。
progress 不传时保持旧值;传了则钳到 0-100 且终态时
progress 不强制改 100(对方可能以 failed 终态报 40%,
那是真实进度,不是“完成度”)。"""
if status not in HANDOFF_STATUSES:
return False, "status must be one of %s" % (
", ".join(HANDOFF_STATUSES),)
if progress is not None:
if isinstance(progress, bool) or not isinstance(progress, int):
return False, "progress must be an integer"
progress = max(0, min(100, progress))
if detail is not None and not isinstance(detail, str):
return False, "detail must be a string"
if detail is not None and len(detail) > 2000:
return False, "detail too long"
if now is None:
now = time.time()
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
if row is None:
return False, "handoff not found"
current = handoff_to_dict(row)
if status not in _HANDOFF_TRANSITIONS[current["status"]] \
and status != current["status"]:
# 同状态重报(例如 running 再报 running + 新进度)允许,
# 其它非法转移拒绝。
return False, ("cannot move handoff from %s to %s"
% (current["status"], status))
new_progress = current["progress"] if progress is None else progress
new_detail = current["detail"] if detail is None else detail.strip()
conn.execute(
"UPDATE task_handoffs SET status = ?, progress = ?,"
" detail = ?, updated_at = ? WHERE id = ?",
(status, new_progress, new_detail, now, handoff_id))
conn.commit()
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
return True, handoff_to_dict(row)
def parse_pr_checks(output):
"""解析 `gh pr checks` 输出(制表符与人类可读两种格式,
与 host.py 的 parse_pr_checks 同规则,server 不能 import
host.py——host 不是包,server 侧只能自带一份解析)。
返回 (checks, all_pass, failed, pending):checks 为
[{name, state}],state 统一成 pass / fail / pending /
skipped / cancelled / neutral;没有任何 check 时 all_pass
为 False(空 PR 不能算全过);failed 是未通过的 check 名,
即“失败原因”的全部内容——gh checks 本身不给日志原文,
日志要到 GitHub 上该 check 的详情页看,本函数不编造
日志链接(check 行里没有 run id,拼不出可靠链接)。"""
checks = []
for raw in (output or "").splitlines():
line = raw.strip()
if not line or "no checks" in line.lower():
continue
if line.lower().startswith("name"):
continue
if "\t" in line:
parts = [p.strip() for p in line.split("\t") if p.strip()]
if len(parts) < 2:
continue
name, state_raw = parts[0], parts[1].lower()
else:
m = re.search(r"\b(pass|passing|fail|failing|failed|pending|"
r"queued|waiting|in_progress|skipped|cancelled|"
r"neutral)\b", line.lower())
if not m:
continue
state_raw = m.group(1)
name = line[:m.start()].strip(" \u2713\u2717\u25cf\u25cb-").strip()
if not name:
name = line
state = {"passing": "pass", "failing": "fail", "failed": "fail",
"queued": "pending", "waiting": "pending",
"in_progress": "pending"}.get(state_raw, state_raw)
checks.append({"name": name, "state": state})
all_pass = bool(checks) and all(c["state"] == "pass" for c in checks)
failed = [c["name"] for c in checks if c["state"] == "fail"]
pending = [c["name"] for c in checks if c["state"] == "pending"]
return checks, all_pass, failed, pending
def worktree_status(task_status, worktree_path, exists, is_git,
environment_status=None, remote_host=None):
"""派生 worktree 创建状态(纯函数,server 负责把文件系统
事实传进来,单测不用真的建目录)。
status 取值:
- remote:worktree 在别机,本机查不到,exists 一律 None。
- ready:目录在且含 .git(git worktree 或仓库检出)。
- present_no_git:目录在但无 .git(非 git 任务的独立目录
拷贝,host.py make_worktree 的退化形态,不是失败)。
- creating:任务还在 queued / running 且目录未出现——
worker 尚未建出来或正在建,轮询方应继续等。
- missing:终态任务且目录不在——merged 后 worktree 已删
是预期行为,不是“创建失败”;queued / running 之外的
终态也同此理,调用方看 task_status 区分。
- unknown:其它组合(例如目录不在且任务状态不在上述
两类),不硬编一个结论。
environment_status(任务绑 environment 时)原样透传,
它是真正异步的创建过程(creating -> ready / failed)。"""
if remote_host:
return {"status": "remote", "path": worktree_path,
"exists": None, "is_git": None,
"task_status": task_status,
"environment_status": environment_status,
"note": "worktree is on host %s; this host cannot"
" see it" % remote_host}
if exists and is_git:
status, note = "ready", ""
elif exists:
status, note = "present_no_git", (
"directory exists without .git (non-git task copy)")
elif task_status in ("queued", "running"):
status, note = "creating", (
"worktree not created yet; worker creates it on claim")
elif task_status in TERMINAL_TASK_STATUSES:
status, note = "missing", (
"worktree not present for a terminal task"
" (merged tasks delete it by design)")
else:
status, note = "unknown", ""
return {"status": status, "path": worktree_path,
"exists": bool(exists), "is_git": bool(is_git),
"task_status": task_status,
"environment_status": environment_status,
"note": note}
def interruptible_sleep(seconds, stop_event=None, step=0.5):
"""步进睡眠(clock.sleep 的控制面原语):seconds 钳到
[0, MAX_SLEEP_SECONDS];stop_event 被置位时提前返回
(slept, True),否则睡满返回 (slept, False)。server 的
/sleep 端点不传 stop_event(客户端断开不中断 handler,
上限 60 秒);host.py 的 sleep 命令用 SIGINT 转 stop。"""
try:
seconds = float(seconds)
except (TypeError, ValueError):
seconds = 0.0
seconds = max(0.0, min(float(MAX_SLEEP_SECONDS), seconds))
slept = 0.0
while slept < seconds:
if stop_event is not None and stop_event.is_set():
return slept, True
chunk = min(step, seconds - slept)
time.sleep(chunk)
slept += chunk
return slept, False
def poll_task_statuses(fetch_statuses, timeout_seconds=30.0,
poll_seconds=WAIT_POLL_SECONDS, now=None):
"""wait_threads 的轮询核:fetch_statuses() 回一个
{task_id: status},直到全部终态或超时。
返回 (all_terminal, statuses, waited_seconds, timed_out):
空任务集立即回 all_terminal=True(没有可等的东西,
不是错误);fetch 抛异常时不吞——调用方(server)按
502 处理,轮询核绝不能把 DB 故障伪装成“超时”。"""
try:
timeout_seconds = float(timeout_seconds)
except (TypeError, ValueError):
timeout_seconds = 30.0
timeout_seconds = max(0.0, min(float(MAX_WAIT_SECONDS),
timeout_seconds))
start = time.time() if now is None else now()
statuses = fetch_statuses()
if all(s in TERMINAL_TASK_STATUSES for s in statuses.values()):
return True, statuses, 0.0, False
while True:
elapsed = (time.time() - start) if now is None else (now() - start)
if elapsed >= timeout_seconds:
return False, statuses, elapsed, True
time.sleep(min(poll_seconds, max(0.0, timeout_seconds - elapsed)))
statuses = fetch_statuses()
if all(s in TERMINAL_TASK_STATUSES for s in statuses.values()):
elapsed = (time.time() - start) if now is None \
else (now() - start)
return True, statuses, elapsed, False
# -- wait_threads:首变 + 游标 ------------------------------------
# 摘要形态(server 的 fetch 回调产出,键名固定):
# {"status": str, "pending_approvals": int,
# "pending_input": int, "pending_followups": int}
# pending_input 是 kind=elicitation 的待审批(向用户要输入),
# 其余 kind(command / file_change / permissions 等)计入
# pending_approvals(要审批)。server 不读链路文件做变化
# 检测:每 0.5 秒全量读 8 个任务的 jsonl 是浪费,状态、
# 审批、后续消息三张表已足够判断“需不需要人处理”。
def encode_wait_cursor(summaries):
"""摘要 -> 不透明游标(base64 的 JSON,调用方原样回传,
不解析、不拼接)。游标只含状态计数,不含任何正文。"""
raw = json.dumps(summaries, ensure_ascii=False,
sort_keys=True).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii")
def decode_wait_cursor(cursor):
"""游标 -> 摘要;非法游标抛 ValueError(调用方转 400,
绝不静默当成“无游标”——那样会把旧变化再报一遍,
违背 afterCursor“只取新变化”的契约)。"""
if not isinstance(cursor, str) or not cursor:
raise ValueError("after_cursor must be a non-empty string")
try:
raw = base64.urlsafe_b64decode(cursor.encode("ascii"))
data = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
raise ValueError("after_cursor is not a valid cursor")
if not isinstance(data, dict):
raise ValueError("after_cursor is not a valid cursor")
for value in data.values():
if not isinstance(value, dict):
raise ValueError("after_cursor is not a valid cursor")
return data
def _summary_numbers(summary):
"""摘要里的计数统一成 int(缺键、脏值一律按 0 计,
不让旧脏行把等待逻辑炸掉)。"""
def _int(value):
try:
return int(value)
except (TypeError, ValueError):
return 0
return (_int(summary.get("pending_approvals")),
_int(summary.get("pending_input")),
_int(summary.get("pending_followups")))
def significant_now(summary):
"""当前摘要是否本身就是“重要状态”(无游标首次等待时用):
终态、待审批、待输入、待继续处理任一成立即算。"""
status = (summary or {}).get("status")
approvals, inputs, followups = _summary_numbers(summary or {})
if status in TERMINAL_TASK_STATUSES:
return "terminal"
if inputs > 0:
return "needs_input"
if approvals > 0:
return "needs_approval"
if followups > 0:
return "needs_continue"
return None
def detect_significant_change(baseline, current, task_ids):
"""按 task_ids 顺序找第一个重要变化,返回
(task_id, kind, detail) 或 None。
- baseline 为 None:只认 significant_now(首次等待,
已终态 / 已阻塞的线程应立即返回,不用等一轮)。
- baseline 为 dict:只认相对 baseline 的新增变化;
baseline 里没有的任务(调用方在新一轮里加进来的)
按“无变化”处理,不把它的当前状态误报成新变化。
- 优先级按检查顺序:终态 > 待输入 > 待审批 > 待继续,
同一任务多类变化时只报最先的一类;不同任务时按
task_ids 顺序,第一个有变化的胜出(“第一个发生
重要变化的线程”按列表顺序定先后,轮询本身没有
跨任务的精确时间戳可比)。"""
for task_id in task_ids:
cur = current.get(task_id)
if not isinstance(cur, dict):
continue
if baseline is None:
kind = significant_now(cur)
if kind is not None:
return task_id, kind, {"status": cur.get("status")}
continue
base = baseline.get(task_id)
if not isinstance(base, dict):
continue
cur_status = cur.get("status")
base_status = base.get("status")
if cur_status in TERMINAL_TASK_STATUSES \
and cur_status != base_status:
return task_id, "terminal", {"status": cur_status}
_ba, bi, _bf = _summary_numbers(base)
ca, ci, cf = _summary_numbers(cur)
if ci > bi:
return task_id, "needs_input", {
"pending_input": ci, "status": cur_status}
if ca > _ba:
return task_id, "needs_approval", {
"pending_approvals": ca, "status": cur_status}
if cf > _bf:
return task_id, "needs_continue", {
"pending_followups": cf, "status": cur_status}
return None
def poll_for_first_change(fetch_summaries, task_ids, baseline=None,
timeout_seconds=30.0,
poll_seconds=WAIT_POLL_SECONDS):
"""wait_threads 的轮询核:fetch_summaries() 回
{task_id: 摘要},直到第一个重要变化或超时。
返回 (changed, summaries, waited_seconds, timed_out):
changed 为 None 表示超时(此时 summaries 是超时那一刻
的进度,调用方用它 + 新游标继续等);changed 非空时
为 {"task_id": ..., "kind": ..., "detail": {...}}。
fetch 抛异常不吞——调用方按 502 处理,不能把 DB 故障
伪装成“超时无变化”。"""
try:
timeout_seconds = float(timeout_seconds)
except (TypeError, ValueError):
timeout_seconds = 30.0
timeout_seconds = max(0.0, min(float(MAX_WAIT_SECONDS),
timeout_seconds))
start = time.time()
summaries = fetch_summaries()
found = detect_significant_change(baseline, summaries, task_ids)
if found is not None:
task_id, kind, detail = found
return {"task_id": task_id, "kind": kind,
"detail": detail}, summaries, 0.0, False
if baseline is None:
baseline = summaries
while True:
elapsed = time.time() - start
if elapsed >= timeout_seconds:
return None, summaries, elapsed, True
time.sleep(min(poll_seconds,
max(0.0, timeout_seconds - elapsed)))
summaries = fetch_summaries()
found = detect_significant_change(baseline, summaries, task_ids)
if found is not None:
task_id, kind, detail = found
return {"task_id": task_id, "kind": kind,
"detail": detail}, summaries, time.time() - start, False
# -- read_pull_request_check_diagnostics:check-runs --------------
# gh pr checks --json 给出的字段:name / state / bucket /
# workflow / link / description / event / startedAt /
# completedAt。state 与 bucket 都可能出现,统一成
# pass / fail / pending / skipped / cancelled / neutral
# (bucket 的 skipping -> skipped、cancel -> cancelled)。
def _normalize_check_state(raw):
state = str(raw or "").lower()
return {"passing": "pass", "failing": "fail", "failed": "fail",
"queued": "pending", "waiting": "pending",
"in_progress": "pending", "skipping": "skipped",
"cancel": "cancelled"}.get(state, state)
def _normalize_json_checks(payload):
"""gh pr checks --json 的输出(list)-> checks 列表。
非 list、缺 name 的行直接跳过,不因此让整批诊断失败。"""
checks = []
if not isinstance(payload, list):
return checks
for item in payload:
if not isinstance(item, dict) or not item.get("name"):
continue
state = _normalize_check_state(
item.get("bucket") or item.get("state"))
checks.append({
"name": item["name"], "state": state,
"workflow": item.get("workflow") or "",
"link": item.get("link") or "",
"description": item.get("description") or "",
"event": item.get("event") or "",
})
return checks
def build_pr_diagnostics(run, pr_number, owner_repo=None, head_sha=None):
"""PR 诊断的组装核:run(cmd) -> (returncode, stdout, stderr),
server 用 cloud.pr._run 适配它(cwd=worktree,runner 可注入)。
步骤(任一步失败都不让已拿到的 checks 白费):
1. `gh pr checks --json ...` 拿 workflow / link / description;
JSON 解析失败时回落制表符解析(老版 gh / 假 runner)。
两路都失败才抛 RuntimeError(调用方转 502)。
2. 有 owner_repo + head_sha 且有失败项时,走 check-runs API
拿失败日志原文与文件行号注释:先 list 找失败 run 的 id,
再逐个(上限 5 个)调单条 check-run 取 output.text 与
annotations(path / start_line / message,上限 20 条,
text 截 4000 字)。list 或单条失败时 check_runs 回
available=False + note,不因此 502——checks 本身已够
回答“哪个检查失败”,日志是加分项不是前置条件。
返回 dict:checks / all_pass / failed / diagnostics /
check_runs(list 或 {available: False, note})。"""
fields = ("bucket,completedAt,description,event,link,name,"
"startedAt,state,workflow")
rc, out, err = run(["gh", "pr", "checks", str(pr_number),
"--json", fields])
checks = []
if rc == 0:
try:
checks = _normalize_json_checks(json.loads(out or ""))
except ValueError:
checks = []
if not checks:
rc2, out2, err2 = run(["gh", "pr", "checks", str(pr_number)])
if rc2 != 0:
raise RuntimeError((err or err2 or "gh pr checks failed")[:500])
parsed, _ap, _f, _p = parse_pr_checks(out2 or "")
checks = [{"name": c["name"], "state": c["state"],
"workflow": "", "link": "",
"description": "", "event": ""}
for c in parsed]
all_pass = bool(checks) and all(
c["state"] == "pass" for c in checks)
failed = [c["name"] for c in checks if c["state"] == "fail"]
diagnostics = ("all checks pass" if all_pass else
("failed checks: %s" % ", ".join(failed) if failed
else "no checks reported"))
check_runs = {"available": False,
"note": "check-runs unavailable: "
"no owner/repo or head sha"}
if owner_repo and head_sha and failed:
try:
check_runs = _fetch_failed_check_runs(
run, owner_repo, head_sha, failed)
except Exception as exc:
check_runs = {"available": False,
"note": "check-runs fetch failed: %s"
% str(exc)[:200]}
return {"checks": checks, "all_pass": all_pass,
"failed": failed, "diagnostics": diagnostics,
"check_runs": check_runs}
def _fetch_failed_check_runs(run, owner_repo, head_sha, failed_names):
"""check-runs API 的两步取数(只查失败项,上限 5 个)。
list 端点回 {check_runs: [...]};单条回 {id, name,
conclusion, output: {title, summary, text, annotations}}。
annotations 缺省为空列表,不因缺字段而失败。"""
rc, out, _err = run(["gh", "api",
"repos/%s/commits/%s/check-runs"
% (owner_repo, head_sha)])
if rc != 0:
raise RuntimeError("gh api check-runs list failed")
try:
payload = json.loads(out or "")
except ValueError:
raise RuntimeError("gh api check-runs list is not JSON")
runs = payload.get("check_runs") if isinstance(payload, dict) else None
if not isinstance(runs, list):
raise RuntimeError("gh api check-runs list has no check_runs")
by_name = {}
for item in runs:
if isinstance(item, dict) and item.get("name"):
by_name.setdefault(item["name"], item)
details = []
for name in failed_names[:5]:
item = by_name.get(name)
if item is None or not item.get("id"):
details.append({"name": name, "available": False,
"note": "no check-run found for this check"})
continue
rc2, out2, _e2 = run(["gh", "api",
"repos/%s/check-runs/%s"
% (owner_repo, item["id"])])
if rc2 != 0:
details.append({"name": name, "available": False,
"note": "check-run fetch failed"})
continue
try:
single = json.loads(out2 or "")
except ValueError:
details.append({"name": name, "available": False,
"note": "check-run is not JSON"})
continue
output = single.get("output") if isinstance(single, dict) else None
if not isinstance(output, dict):
output = {}
annotations = []
for ann in output.get("annotations") or []:
if not isinstance(ann, dict):
continue
annotations.append({
"path": ann.get("path") or "",
"start_line": ann.get("start_line"),
"end_line": ann.get("end_line"),
"level": ann.get("annotation_level") or "",
"message": (ann.get("message") or "")[:500],
})
if len(annotations) >= 20:
break
details.append({
"name": name, "available": True,
"check_run_id": item["id"],
"conclusion": single.get("conclusion") or "",
"title": output.get("title") or "",
"summary": (output.get("summary") or "")[:1000],
"log_text": (output.get("text") or "")[:4000],
"annotations": annotations,
})
return {"available": True, "runs": details}