Files
FreeCADWasmDev/tools/scheduler_round_report.py

210 lines
11 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.
"""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()