700 lines
30 KiB
Python
700 lines
30 KiB
Python
"""线程等待与移交操作(对照 codex app 的 8 项工具面)。
|
||
|
||
本模块只做控制面纯逻辑,不跑模型、不联网;行取值复用
|
||
cloud.peer_messages(该模块本身只依赖标准库,故不产生循环
|
||
import),其余不 import cloud 其它模块;
|
||
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
|
||
|
||
from cloud.peer_messages import row_get
|
||
|
||
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_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}
|
||
|