210 lines
11 KiB
Python
210 lines
11 KiB
Python
"""Publish concise round results and confirmed continuation, without running work."""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import fcntl
|
||
from datetime import datetime, timezone
|
||
import json
|
||
from pathlib import Path
|
||
import re
|
||
import time
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
_last_refresh = {}
|
||
LABELS = {
|
||
"source_edits_pending_outcome": "已修改,整项验收未完成",
|
||
"no_progress": "本轮未形成可确认交付",
|
||
"provider_wait": "上游等待,本轮未完成",
|
||
"progress": "本轮有进展,未新增整项通过",
|
||
"maintenance_completed": "巡检回合结束,具体修复见记录",
|
||
"maintenance_incomplete": "巡检未完成,保留后续处理",
|
||
"maintenance_repair_with_boundaries": "巡检有修改,完成情况见说明",
|
||
}
|
||
|
||
|
||
def read(path):
|
||
try:
|
||
value = json.loads(path.read_text())
|
||
return value if isinstance(value, dict) else {}
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
|
||
def write(path, value):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_suffix(path.suffix + ".round.tmp")
|
||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||
temporary.replace(path)
|
||
|
||
|
||
def reason_text(value):
|
||
text = str(value or "")
|
||
try:
|
||
result = json.loads(text)
|
||
except ValueError:
|
||
return text
|
||
if not isinstance(result, dict):
|
||
return text
|
||
if result.get("status") == "acceptance_required":
|
||
return "完整验收用例尚未齐备,该需求仍需继续实现或补齐验证。"
|
||
if result.get("status") in {"previous_validation_deferred", "unchanged_failure_preserved"}:
|
||
return "保留已有证据,未重复运行相同输入;后续继续处理实际缺口。"
|
||
return str(result.get("reason") or result.get("error") or result.get("status") or text)[:1200]
|
||
|
||
|
||
def cycle_summary(root, cycle):
|
||
path = root / "docs/current/continuation-runs" / f"cycle-{cycle:06d}.log"
|
||
try:
|
||
with path.open("rb") as stream:
|
||
stream.seek(0, 2)
|
||
stream.seek(max(0, stream.tell() - 512 * 1024))
|
||
lines = stream.read().decode("utf-8", "replace").splitlines()
|
||
except OSError:
|
||
return None
|
||
for line in reversed(lines):
|
||
try:
|
||
event = json.loads(line)
|
||
except ValueError:
|
||
continue
|
||
if not isinstance(event, dict):
|
||
continue
|
||
item = event.get("item", {})
|
||
if event.get("type") == "item.completed" and item.get("type") == "agent_message":
|
||
return item.get("text")
|
||
return None
|
||
|
||
|
||
def completed_row(event, status, summary=None):
|
||
first = list(event.get("firstBusinessPassIds", []))
|
||
outcome = str(event.get("outcome", "unknown"))
|
||
label = f"新增整项通过 {len(first)} 项" if first else LABELS.get(outcome, "本轮状态待核实")
|
||
if event.get("evidenceRefreshOnly"):
|
||
label = "证据刷新,无新增整项交付"
|
||
blocker = reason_text(event.get("reason"))
|
||
detail = summary or blocker or "未提供结果说明"
|
||
if status.get("cycle") == event.get("cycle") and status.get("lastAgentMessage"):
|
||
detail = status["lastAgentMessage"]
|
||
return {"cycle": event.get("cycle"), "module": event.get("module") or "未指定工作台",
|
||
"batchId": event.get("batchId"), "outcome": outcome, "result": label,
|
||
"firstPassIds": first, "changedFiles": event.get("fileChanges", 0),
|
||
"seconds": event.get("seconds", 0), "detail": str(detail)[:2400],
|
||
"remaining": blocker if blocker and blocker.strip() != str(detail).strip() else None,
|
||
"finishedAt": event.get("at"), "evidence": event.get("evidence", [])}
|
||
|
||
|
||
def confirmed_task(status, batch, last_cycle):
|
||
cycle = status.get("cycle", 0) or 0
|
||
if cycle <= last_cycle:
|
||
return None
|
||
if status.get("executionMode") == "periodic-optimization" and status.get("status") in {
|
||
"periodic_optimization_starting", "codex_turn_running", "codex_turn_starting"}:
|
||
return {"cycle": cycle, "module": "巡检", "batchId": status.get("maintenanceRunId"),
|
||
"tasks": ["检查占位、阻塞与空转,按证据定点修复"], "ids": []}
|
||
if status.get("status") not in {"codex_turn_starting", "codex_turn_running", "post_turn_pipeline"}:
|
||
return None
|
||
if batch.get("status") != "ready" or not batch.get("batchId") or batch.get("batchId") != status.get("moduleBatchId"):
|
||
return None
|
||
rows = [row for row in batch.get("records", []) if isinstance(row, list) and row]
|
||
return {"cycle": cycle, "module": batch.get("selected", {}).get("module"), "batchId": batch["batchId"],
|
||
"tasks": [str(row[1] if len(row) > 1 else row[0]) for row in rows], "ids": [str(row[0]) for row in rows]}
|
||
|
||
|
||
def _refresh(status=None, *, root=ROOT, force=False, announce=True):
|
||
root = Path(root)
|
||
current = root / "docs/current"
|
||
status = read(current / "freecad-web-scheduler-status.json") if status is None else status
|
||
key = (status.get("cycle"), status.get("status"), status.get("progressOutcome"), status.get("moduleBatchId"))
|
||
previous_time, previous_key = _last_refresh.get(str(root), (0, None))
|
||
now = time.monotonic()
|
||
if not force and key == previous_key and now - previous_time < 5:
|
||
return None
|
||
progress = read(current / "scheduler-progress.json")
|
||
old = read(current / "round-report.json")
|
||
existing = {row["cycle"]: row for row in old.get("rounds", []) if isinstance(row, dict) and row.get("cycle") is not None} if old.get("formatVersion") == 3 else {}
|
||
# Refresh legacy wording in place without reloading old agent transcripts.
|
||
for row in existing.values():
|
||
if row.get("outcome") == "progress" and row.get("result") == "业务状态有推进" and not row.get("firstPassIds"):
|
||
row["result"] = LABELS["progress"]
|
||
write(current / "round-reports" / f"cycle-{row['cycle']:06d}.json", row)
|
||
ledger = None
|
||
for event in progress.get("history", [])[-20:]:
|
||
if not isinstance(event, dict) or not isinstance(event.get("cycle"), int):
|
||
continue
|
||
if event["cycle"] not in existing:
|
||
row = completed_row(event, status, cycle_summary(root, event["cycle"]))
|
||
# Heartbeats do not need the full business evidence ledger. Read
|
||
# it only when a new completed round actually cites first passes.
|
||
if row["firstPassIds"] and ledger is None:
|
||
ledger = read(current / "business-acceptance.json").get("records", {})
|
||
for record_id in row["firstPassIds"]:
|
||
evidence = ledger.get(record_id, {}).get("evidencePath")
|
||
if isinstance(evidence, str) and evidence.startswith("docs/current/business-evidence/"):
|
||
row["evidence"].append({"id": record_id, "path": evidence})
|
||
existing[event["cycle"]] = row
|
||
write(current / "round-reports" / f"cycle-{event['cycle']:06d}.json", row)
|
||
maintenance = read(current / "scheduler-maintenance.json")
|
||
log = maintenance.get("lastLog", "") or ""
|
||
match = re.search(r"cycle-(\d+)\.log$", log)
|
||
result_path = maintenance.get("lastResultPath")
|
||
if match and isinstance(result_path, str):
|
||
candidate = (root / result_path).resolve()
|
||
if candidate.is_relative_to((current / "periodic-optimization").resolve()):
|
||
result = read(candidate)
|
||
cycle = int(match[1])
|
||
if result and cycle not in existing:
|
||
repaired = int(result.get("fileChanges", 0) or 0) > 0 and bool(result.get("summary"))
|
||
event = {"cycle": cycle, "module": "巡检", "batchId": result.get("runId"),
|
||
"outcome": "provider_wait" if result.get("providerFailed") else "maintenance_completed" if result.get("turnCompleted") else "maintenance_repair_with_boundaries" if repaired else "maintenance_incomplete",
|
||
"reason": result.get("summary") or result.get("error") or result.get("status"),
|
||
"fileChanges": result.get("fileChanges", 0), "seconds": result.get("elapsedSeconds", 0),
|
||
"at": result.get("finishedAt")}
|
||
existing[cycle] = completed_row(event, {})
|
||
write(current / "round-reports" / f"cycle-{cycle:06d}.json", existing[cycle])
|
||
rounds = sorted(existing.values(), key=lambda row: row["cycle"])[-20:]
|
||
last = rounds[-1] if rounds else None
|
||
task = confirmed_task(status, read(current / "module-batch-manifest.json"), last["cycle"] if last else 0)
|
||
payload = {"schema": "freecad-web-round-report/v1", "formatVersion": 3, "updatedAt": datetime.now(timezone.utc).isoformat(),
|
||
"reportingActive": status.get("roundReportVersion") == 1,
|
||
"heartbeatAt": status.get("heartbeatAt"), "schedulerStatus": status.get("status"),
|
||
"currentCycle": status.get("cycle"), "lastCompleted": last, "nextTask": task,
|
||
"nextTaskState": "confirmed" if task else "pending", "rounds": rounds}
|
||
if announce and last and last["cycle"] > int(old.get("lastAnnouncedCycle", 0)):
|
||
print(f"\n【第 {last['cycle']} 轮结束 · {last['module']}】{last['result']};耗时 {last['seconds']} 秒\n"
|
||
f"本轮说明:{last['detail']}\n下一轮任务:待调度确认\n", flush=True)
|
||
identity = [task["cycle"], task["batchId"]] if task else None
|
||
if announce and task and identity != old.get("lastAnnouncedTask"):
|
||
print(f"【接续任务已确认 · 第 {task['cycle']} 轮 · {task['module']}】{';'.join(task['tasks'])}", flush=True)
|
||
private = {**payload, "lastAnnouncedCycle": (last["cycle"] if last else 0) if announce else old.get("lastAnnouncedCycle", last["cycle"] if last else 0),
|
||
"lastAnnouncedTask": (identity or old.get("lastAnnouncedTask")) if announce else old.get("lastAnnouncedTask")}
|
||
write(current / "round-report.json", private)
|
||
write(root / "web/public/execution-status.json", payload)
|
||
_last_refresh[str(root)] = (now, key)
|
||
return payload
|
||
|
||
|
||
def refresh(status=None, *, root=ROOT, force=False, announce=True):
|
||
lock_path = Path(root) / "docs/current/round-report.lock"
|
||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with lock_path.open("a") as lock:
|
||
try:
|
||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except BlockingIOError:
|
||
return None
|
||
return _refresh(status, root=root, force=force, announce=announce)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--refresh", action="store_true", help="publish a read-only snapshot from existing scheduler records")
|
||
args = parser.parse_args()
|
||
payload = (refresh(force=True, announce=False) if args.refresh else None) or read(ROOT / "docs/current/round-report.json")
|
||
last = payload.get("lastCompleted")
|
||
if last:
|
||
print(f"第 {last['cycle']} 轮 · {last['module']}:{last['result']}\n{last['detail']}")
|
||
task = payload.get("nextTask")
|
||
print(f"接续任务:第 {task['cycle']} 轮 · {task['module']} · {';'.join(task['tasks'])}" if task else "下一轮任务:待调度确认")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|