#!/usr/bin/env python3 # -*- coding: utf-8 -*- """graphctl.py —— 「图模式」的独立实现(2026-10-18;2026-10-19 补批量能力)。 ## 它是什么 把目标拆成节点、按依赖推进的**独立**运行模式。**⛔ 不修改任何现有机制**: 不读也不写 `goal.json` / `tasks.json` / `collabd-state.json`,不参与六道闸, 不接管常驻循环,不改变现有会话的任何行为。 ## 数据落点(独立目录,与现有状态完全分开) /.workbuddy/graph//graph.json 图的唯一真源 /.workbuddy/graph//events.jsonl 每次状态变更的流水 ## 图的结构 { "gid": "20261018-2230-ui改造", "title": "给三块功能各加一份文档", "created_at": "2026-10-18T22:30:00", "meta": {"max_parallel": 3, "lease": {"timeout_sec": 3600, "on_timeout": "requeue"}, "retry": {"max": 0, "delay_sec": 0, "backoff": "fixed", "factor": 2}}, "nodes": { "N1": {"title": "...", "deps": [], "domain": ["docs/ui/"], "accepts": ["产物在位"], "status": "pending", "evidence": "", "artifacts": [], "attempts": 0, "deps_rule": "all_success", "retry": {"max": 2, "delay_sec": 30, "backoff": "exponential"}, "map": {"over": ["a", "b"], "child_domain": "docs/ui/{i}/"}} } } `status` 五态:`pending` → `running` → `done` / `blocked`; 另有 `gating`——展开出来的父节点,等子节点全部完成(父节点自动转 `done`)。 ## 命令 init --ws <工作区> --title <目标> (--kpi "判据A;判据B" | --nodes-json <文件|->) [--max-parallel N] [--lease-sec S] [--on-timeout requeue|block|keep] [--retry-max N] [--retry-delay S] [--retry-backoff fixed|linear|exponential] check --ws --gid 环/悬空依赖/空域/同批域冲突/非法规则 ready --ws --gid 就绪集(含为什么没被选);顺带回收超时租约、展开 map start --ws --gid --node N1 [--by ...] [--lease-sec S] report --ws --gid --node N1 --state done|blocked [--evidence "..."] [--artifact P ...] expand --ws --gid --node P [--force] 手动触发展开 show --ws --gid list --ws tick --ws --gid [--by ...] [--limit N] [--dry-run] 自动派活(算完就开) approve --ws --gid --node N [--reject] [--reason ...] 人审 cancel --ws --gid (--node N | --all) [--reason ...] 取消 plan --ws --gid [--scope ...] [--after-sec N] 出一行排期 progress --ws --gid 进度/关键路径 ## 复杂任务能力(2026-10-19 补,C 方案) 条件分支:节点 `when: [{"node": "A", "status": "blocked"}]` —— 条件被证伪即 `skipped`;`on_dep_fail: wait|skip|block` 决定上游跑不成时下游怎么走 (默认 wait,与旧行为一致)。 子图:节点 `subgraph: {"nodes": [...]}` —— 内联子流程展开成 `<父>/<子id>`, 父节点 `gating`,子节点全成功才 `done`,有不成功的 ⇒ 父 `blocked`。 人审:节点 `approval: true` —— report done 后进 `awaiting`,`approve` 才放行 (`--reject` ⇒ blocked)。 取消:`cancelled` 是终态,连坐子节点;下游按 `on_dep_fail` 处置。 分池:`meta.pools: {"域前缀": 并发上限}` —— 同域并发受限,与 `max_parallel` 叠加。 进度:`progress` 给出 by_status/percent/关键路径(未完成节点的最长依赖链)。 ## 就绪的判据 ① 依赖按 `deps_rule` 满足(默认 all_success) ② 自己还是 pending,且不在重试等待窗内(`not_before` 未到 ⇒ 等) ③ 域不与任何 running 节点重叠,也不与本批已选节点重叠 ④ 不超 max_parallel(running 占名额) ## 三件批量能力(2026-10-19 补) 租约:start 记 `lease_at` / `lease_at_ts`;ready 时超过 `lease.timeout_sec` 的 running 节点按 `on_timeout` 处置 —— requeue(默认,回 pending 且 attempts+1)/block(进 blocked)/keep(只提示不动)。 重试:`retry.max` 给了才自动重试,report blocked 后 attempts≤max ⇒ 回 pending 并写 `not_before`(按 backoff 算),超过 ⇒ blocked 且 `retry_exhausted`。 默认 `max=0` ⇒ 与旧行为一致(report blocked 直接 blocked)。 展开:`map.over`(建图给列表)或 `map.from`(运行时读上游 artifacts / evidence), ready 命中即生成子节点 `P#1..P#N`,父节点转 `gating`,子节点全 done ⇒ 父 done。 ## 域的用法 `domain` 是一组路径前缀(相对工作区根)。两节点域重叠 ⇒ 不能同批跑。 判定:任一前缀互为前缀即视为重叠(`docs/` 与 `docs/ui/` 算重叠)。 展开出的子节点**默认不继承父域**(同一批展开通常写同一批文件,继承会把并发挡死); 需要分片写盘时用 `map.child_domain` 模板(`{i}` / `{item}`)。 """ from __future__ import annotations import argparse import io import json import math import os import random import sys import time from pathlib import Path MARK = ".workbuddy/graph" LOCK_STALE_SEC = 30.0 # 锁目录超过这么久没动静 ⇒ 认定持锁进程已死,接管 STATUSES = ("pending", "running", "done", "blocked", "gating", "awaiting", "skipped", "cancelled") SETTLED = ("done", "blocked", "skipped", "cancelled") # 不会再自己变了 DEP_RULES = ("all_success", "all_done", "one_success") TIMEOUT_POLICIES = ("requeue", "block", "keep") BACKOFFS = ("fixed", "linear", "exponential") ON_DEP_FAIL = ("wait", "skip", "block") # 上游跑不成时下游怎么办 DEFAULT_LEASE_SEC = 3600.0 # ── 基础设施 ──────────────────────────────────────────────────────────── def _now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%S") def _iso(ts: float) -> str: return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts)) def _parse_ts(v): """ISO 字符串 → epoch;失败返回 None(⛔ 不抛,坏数据当作"没有租约")。""" if v in (None, ""): return None try: return float(v) except (TypeError, ValueError): pass for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: return time.mktime(time.strptime(str(v), fmt)) except ValueError: continue return None def _root(ws: str) -> Path: return Path(ws).resolve() / MARK def _gdir(ws: str, gid: str) -> Path: return _root(ws) / gid def _load(ws: str, gid: str) -> dict: p = _gdir(ws, gid) / "graph.json" if not p.is_file(): raise SystemExit("找不到图:%s" % p) return json.loads(p.read_text(encoding="utf-8")) def _save(ws: str, gid: str, g: dict) -> None: """整份写回(新建图时用)。临时名带 pid + 随机数 ⇒ 两进程不会撞同一个名字。""" p = _gdir(ws, gid) / "graph.json" tmp = p.with_suffix(".%d.%d.json.tmp" % (os.getpid(), random.randint(0, 1 << 30))) tmp.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") os.replace(tmp, p) # 原子替换,避免半截文件 class _Lock: """写串行化:`os.mkdir` 是原子的,用它当互斥量(⛔ 不用 OS 文件锁,本机会卡死)。 ⚠️ 2026-10-19 实测:只靠 `_rev` + token 回读核对仍然丢更新 —— A 写完回读确认后、B 再覆盖,B 回读看到的也是自己的 token,两边都"成功"。 那次实测里 4 轮测试抖 1 轮(`pending` != `running`)。所以写必须串行。 ⚠️ 僵死锁:持锁进程崩了锁目录会留着 ⇒ 超过 `LOCK_STALE_SEC` 直接接管, ⛔ 不会永久卡住;等 `wait` 秒仍拿不到则**报错退出**(带手工清理提示)。 """ def __init__(self, p: Path, wait: float = 10.0): self.p = p self.wait = wait self.held = False def __enter__(self): t0 = time.time() while True: try: os.mkdir(self.p) self.held = True try: (self.p / "owner").write_text(str(os.getpid()), encoding="utf-8") except OSError: pass return self except FileExistsError: try: age = time.time() - self.p.stat().st_mtime except OSError: age = 0.0 if age > LOCK_STALE_SEC: for f in ("owner",): try: (self.p / f).unlink() except OSError: pass try: os.rmdir(self.p) except OSError: pass continue if time.time() - t0 > self.wait: raise SystemExit( "写锁等待超时:%s(等了 %.0f 秒;确认没有僵死进程后可直接删这个目录)" % (self.p, self.wait)) time.sleep(0.02) def __exit__(self, *exc): if self.held: try: (self.p / "owner").unlink() except OSError: pass try: os.rmdir(self.p) except OSError: pass return False def _update(ws: str, gid: str, mutate, retries: int = 3) -> dict: """读-改-写:**先拿目录锁再动文件**,锁内重放变更并原子替换。 ① 临时名带 pid + 随机数 ⇒ 两进程不会撞同一个名字(早期固定名会 `PermissionError`)。 ② 写整体串行(`_Lock`)⇒ 不会"后写的整份覆盖前写的"导致节点状态静默消失。 ③ `_rev` + `_token` 是第二道网:写后回读核对,对不上就重做; 连续 `retries` 次仍被覆盖则**报错退出**(⛔ 不静默丢状态)。 ④ Windows 上 `os.replace` 与别的进程的读并发时会短暂 `PermissionError` ⇒ 替换动作本身小步重试,⛔ 不把它当成"并发冲突"直接失败。 """ p = _gdir(ws, gid) / "graph.json" if not p.is_file(): raise SystemExit("找不到图:%s" % p) with _Lock(p.parent / ".lock"): for _ in range(retries): g = json.loads(p.read_text(encoding="utf-8")) rev = int(g.get("_rev") or 0) mutate(g) token = "%d-%d" % (os.getpid(), random.randint(0, 1 << 30)) g["_rev"] = rev + 1 g["_token"] = token tmp = p.with_suffix(".%d.%d.json.tmp" % (os.getpid(), random.randint(0, 1 << 30))) tmp.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") for _try in range(6): # 目标被读打开时 replace 会短暂被拒 try: os.replace(tmp, p) break except OSError: time.sleep(0.05) else: try: tmp.unlink() except OSError: pass continue try: back = json.loads(p.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 back = {} if int(back.get("_rev") or 0) == rev + 1 and back.get("_token") == token: return back raise SystemExit("并发冲突:%s 连续 %d 次被别的进程覆盖,稍后重试" % (p, retries)) def _event(ws: str, gid: str, obj: dict) -> None: p = _gdir(ws, gid) / "events.jsonl" obj = dict(obj) obj.setdefault("at", _now()) with io.open(p, "a", encoding="utf-8", newline="\n") as f: f.write(json.dumps(obj, ensure_ascii=False) + "\n") def _out(obj) -> None: data = json.dumps(obj, ensure_ascii=False, indent=2) + "\n" sys.stdout.buffer.write(data.encode("utf-8")) # ── 域与依赖 ──────────────────────────────────────────────────────────── def _norm_dom(d) -> list: if isinstance(d, str): d = [d] out = [] for x in (d or []): s = str(x).strip().replace("\\", "/").lstrip("./") if s: out.append(s if s.endswith("/") else s + "/") return out def _dom_overlap(a, b) -> bool: """任一前缀互为前缀 ⇒ 重叠(docs/ 与 docs/ui/ 算重叠)。""" for x in a: for y in b: if x.startswith(y) or y.startswith(x): return True return False def _deps(n: dict) -> list: v = n.get("deps") or [] return [str(x) for x in v] if isinstance(v, list) else [] def _is_done(nodes: dict, nid: str) -> bool: return str((nodes.get(nid) or {}).get("status")) == "done" def _st(nodes: dict, nid: str) -> str: return str((nodes.get(nid) or {}).get("status")) def _dep_verdict(nodes: dict, n: dict) -> dict: """依赖判定 ⇒ {"verdict": go|wait|skip|block, "why": 说明} ① `when` 条件子句先判(条件分支):全部成立 ⇒ 继续;任一被证伪 ⇒ skip。 ② 再按 `deps_rule` 判:all_success(默认)/all_done/one_success。 ③ 上游停在 skipped / cancelled / blocked 时按 `on_dep_fail` 处置: wait(默认,与旧行为一致)/skip(连坐跳过)/block(整枝停住)。 """ deps = _deps(n) rule = str(n.get("deps_rule") or "all_success") on_fail = str(n.get("on_dep_fail") or "wait") if on_fail not in ON_DEP_FAIL: on_fail = "wait" st = {d: _st(nodes, d) for d in deps} for c in (n.get("when") or []): if not isinstance(c, dict): continue cnode = str(c.get("node") or "") want = str(c.get("status") or "done") got = _st(nodes, cnode) if got == want: continue if got in SETTLED: return {"verdict": "skip", "why": "条件不成立:%s 是 %s,要 %s" % (cnode, got, want)} if not deps: return {"verdict": "wait", "why": "条件节点 %s 还没定论(%s)" % (cnode, got)} if not deps: return {"verdict": "go", "why": ""} dead = [(d, s) for d, s in st.items() if s in ("skipped", "cancelled")] \ or [(d, s) for d, s in st.items() if s == "blocked"] if rule == "all_done": lack = [d for d, s in st.items() if s not in SETTLED] if lack: return {"verdict": "wait", "why": "依赖未跑完:%s" % ", ".join(lack)} return {"verdict": "go", "why": ""} if rule == "one_success": if any(s == "done" for s in st.values()): return {"verdict": "go", "why": ""} if dead and all(s in SETTLED for s in st.values()): return {"verdict": on_fail if on_fail != "wait" else "wait", "why": "没有任一上游成功:%s" % ", ".join("%s(%s)" % (d, s) for d, s in st.items())} return {"verdict": "wait", "why": "等待任一上游成功:%s" % ", ".join("%s(%s)" % (d, s) for d, s in st.items())} lack = [d for d, s in st.items() if s != "done"] if not lack: return {"verdict": "go", "why": ""} if dead: why = "上游不会成功了:%s" % ", ".join("%s(%s)" % (d, s) for d, s in dead) if on_fail != "wait": return {"verdict": on_fail, "why": why} return {"verdict": "wait", "why": "依赖未完成:%s" % ", ".join("%s(%s)" % (d, st[d]) for d in lack)} return {"verdict": "wait", "why": "依赖未完成:%s" % ", ".join("%s(%s)" % (d, st[d]) for d in lack)} def _deps_ok(nodes: dict, n: dict): """按 `deps_rule` 判依赖是否放行,返回 (bool, 没满足的原因)。""" v = _dep_verdict(nodes, n) return v["verdict"] == "go", v["why"] # ── 三件批量能力的配置读法 ────────────────────────────────────────────── def _meta(g: dict) -> dict: m = g.get("meta") return m if isinstance(m, dict) else {} def _lease_cfg(g: dict) -> dict: d = _meta(g).get("lease") d = d if isinstance(d, dict) else {} try: sec = float(d.get("timeout_sec", DEFAULT_LEASE_SEC)) except (TypeError, ValueError): sec = DEFAULT_LEASE_SEC pol = str(d.get("on_timeout") or "requeue") return {"timeout_sec": sec, "on_timeout": pol if pol in TIMEOUT_POLICIES else "requeue"} def _node_lease_sec(g: dict, n: dict) -> float: """节点级 `lease_sec` 优先于全局;0 表示该节点不判超时。""" v = n.get("lease_sec") if v in (None, ""): return _lease_cfg(g)["timeout_sec"] try: return float(v) except (TypeError, ValueError): return _lease_cfg(g)["timeout_sec"] def _retry_cfg(g: dict, n: dict) -> dict: d = {} for src in (_meta(g).get("retry"), n.get("retry")): if isinstance(src, dict): d.update(src) def _num(k, dv): try: return float(d.get(k, dv)) except (TypeError, ValueError): return float(dv) bk = str(d.get("backoff") or "fixed") return {"max": max(0, int(_num("max", 0))), "delay_sec": max(0.0, _num("delay_sec", 0)), "backoff": bk if bk in BACKOFFS else "fixed", "factor": max(1.0, _num("factor", 2))} def _delay_of(rc: dict, attempts: int) -> float: k = max(1, int(attempts)) if rc["backoff"] == "linear": return rc["delay_sec"] * k if rc["backoff"] == "exponential": return rc["delay_sec"] * (rc["factor"] ** (k - 1)) return rc["delay_sec"] # ── 校验 ──────────────────────────────────────────────────────────────── def _find_cycle(nodes: dict) -> list: color, stack, hit = {}, [], [] def dfs(u): color[u] = 1 stack.append(u) for v in _deps(nodes.get(u) or {}): if v not in nodes: continue if color.get(v) == 1: i = stack.index(v) hit.extend(stack[i:] + [v]) return True if color.get(v, 0) == 0 and dfs(v): return True stack.pop() color[u] = 2 return False for k in nodes: if color.get(k, 0) == 0 and dfs(k): return hit return [] def cmd_check(a) -> int: g = _load(a.ws, a.gid) nodes = g.get("nodes") or {} errs, warns = [], [] cyc = _find_cycle(nodes) if cyc: errs.append("存在环:%s" % " → ".join(cyc)) for nid, n in nodes.items(): for d in _deps(n): if d not in nodes: errs.append("%s 的依赖 %s 不存在" % (nid, d)) if str(n.get("deps_rule") or "all_success") not in DEP_RULES: errs.append("%s 的 deps_rule 非法:%r(只认 %s)" % (nid, n.get("deps_rule"), " / ".join(DEP_RULES))) m = n.get("map") if m is not None: if not isinstance(m, dict) or not ("over" in m or "from" in m): errs.append("%s 的 map 需要 over(建图给列表)或 from(运行时读上游)" % nid) else: src = (m.get("from") or {}).get("node") if isinstance(m.get("from"), dict) else None if src and src not in nodes: errs.append("%s 的 map.from.node %s 不存在" % (nid, src)) elif src and src not in _deps(n): warns.append("%s 的展开源 %s 不在 deps 里 ⇒ 上游没完成时会被当成" "「展开源未就绪」空等" % (nid, src)) if n.get("map") and n.get("subgraph"): errs.append("%s 同时声明了 map 与 subgraph(只能选一个)" % nid) sg = n.get("subgraph") if sg is not None: kids = sg.get("nodes") if isinstance(sg, dict) else None if not isinstance(kids, list): errs.append('%s 的 subgraph 要写 {"nodes": [...]}' % nid) kids = [] seen = set() for s in kids: sid = str((s or {}).get("id") or "") if not sid: warns.append("%s 的子图节点缺 id ⇒ 会按顺序补 S1/S2,deps 引用会指不到" % nid) elif sid in seen: errs.append("%s 的子图里有重复 id:%s" % (nid, sid)) else: seen.add(sid) for s in kids: for d in ((s or {}).get("deps") or []): if str(d) not in seen and str(d) not in nodes: errs.append("%s/%s 的依赖 %s 既不在子图里也不在图里" % (nid, (s or {}).get("id") or "?", d)) if str(n.get("on_dep_fail") or "wait") not in ON_DEP_FAIL: errs.append("%s 的 on_dep_fail 非法:%r(只认 %s)" % (nid, n.get("on_dep_fail"), " / ".join(ON_DEP_FAIL))) for c in (n.get("when") or []): if not isinstance(c, dict) or not str(c.get("node") or ""): errs.append('%s 的 when 子句要写 {"node": "A", "status": "done"}' % nid) continue if str(c["node"]) not in nodes: errs.append("%s 的 when 条件节点 %s 不存在" % (nid, c["node"])) if str(c.get("status") or "done") not in STATUSES: errs.append("%s 的 when 期望状态非法:%r" % (nid, c.get("status"))) if n.get("expanded"): for cid in (n.get("children") or []): if cid not in nodes: errs.append("%s 的子节点 %s 不见了(图被手改过?)" % (nid, cid)) if not _norm_dom(n.get("domain")) and not n.get("parent"): warns.append("%s 没有声明域 ⇒ 域比较视为不冲突,可与任意节点同批;" "保守做法是让它单独跑" % nid) if str(n.get("status")) not in STATUSES: errs.append("%s 的状态非法:%r" % (nid, n.get("status"))) if not (n.get("accepts") or []) and not n.get("map") and not n.get("parent"): warns.append("%s 没有验收判据 ⇒ 判不出完成" % nid) pools = _meta(g).get("pools") if pools is not None: if not isinstance(pools, dict): errs.append('meta.pools 要写成 {"域前缀": 并发上限}') else: doms = [(_norm_dom(n.get("domain")), nid) for nid, n in nodes.items()] for k, v in pools.items(): try: cap = int(v) except (TypeError, ValueError): errs.append("meta.pools[%s] 不是数字:%r" % (k, v)) continue if cap <= 0: errs.append("meta.pools[%s] 必须是正整数" % k) pre = _norm_dom(k) if not pre: errs.append("meta.pools 的域不能为空") elif not any(d and _dom_overlap(pre, d) for d, _n in doms): warns.append("域池 %s 没有匹配任何节点的域" % k) ids = list(nodes) for i in range(len(ids)): for j in range(i + 1, len(ids)): a1, a2 = ids[i], ids[j] if _norm_dom(nodes[a1].get("domain")) and \ _dom_overlap(_norm_dom(nodes[a1].get("domain")), _norm_dom(nodes[a2].get("domain"))): warns.append("%s 与 %s 的域重叠 ⇒ 永不同批" % (a1, a2)) _out({"ok": not errs, "errors": errs, "warnings": warns, "nodes": len(nodes)}) return 0 if not errs else 1 # ── 租约超时回收 ──────────────────────────────────────────────────────── def _lease_over(g: dict, n: dict, now: float): """超时则返回超时秒数,否则 None。没有租约时刻 ⇒ 不判超时(兼容手改的旧图)。""" if str(n.get("status")) != "running": return None sec = _node_lease_sec(g, n) if sec <= 0: return None ts = _parse_ts(n.get("lease_at_ts")) if ts is None: ts = _parse_ts(n.get("lease_at")) if ts is None: return None over = now - ts - sec return over if over > 0 else None def _reclaim_leases(g: dict, acts: list) -> int: nodes = g.get("nodes") or {} lc = _lease_cfg(g) now = time.time() changed = 0 for nid, n in list(nodes.items()): over = _lease_over(g, n, now) if over is None: continue pol = str(n.get("on_timeout") or lc["on_timeout"]) if pol == "keep": continue # 只提示,不动(notice 由外层补) n["attempts"] = int(n.get("attempts") or 0) + 1 n["reclaimed_at"] = _now() n["reclaim_count"] = int(n.get("reclaim_count") or 0) + 1 for k in ("lease_at", "lease_at_ts", "lease_sec"): n.pop(k, None) if pol == "block": n["status"] = "blocked" n["blocked_by"] = "lease_timeout" else: n["status"] = "pending" acts.append({"node": nid, "event": "lease_timeout", "to": n["status"], "over_sec": int(over), "attempts": n["attempts"], "policy": pol}) changed += 1 return changed def _lease_notices(g: dict) -> list: lc = _lease_cfg(g) if lc["on_timeout"] != "keep": return [] now = time.time() out = [] for nid, n in (g.get("nodes") or {}).items(): over = _lease_over(g, n, now) if over is not None: out.append({"node": nid, "over_sec": int(over), "why": "租约超时但策略为 keep(只提示,不回收)"}) return out # ── 动态展开(map / foreach)───────────────────────────────────────────── def _as_text(x) -> str: if isinstance(x, (dict, list)): return json.dumps(x, ensure_ascii=False) return str(x) def _parse_list(v) -> list: """证据/产物 → 列表。JSON 数组优先,否则按行与分号切。""" if isinstance(v, list): return list(v) s = str(v or "").strip() if not s: return [] if s[0] in "[(": try: got = json.loads(s) if isinstance(got, list): return list(got) except Exception: # noqa: BLE001 pass out = [] for line in s.replace("\r\n", "\n").replace(";", "\n").replace(";", "\n").split("\n"): t = line.strip().lstrip("-*").strip() if t: out.append(t) return out def _map_items(g: dict, nodes: dict, nid: str): """取展开项;源还没就绪 ⇒ None(区别于空列表 = N=0)。""" n = nodes.get(nid) or {} m = n.get("map") if not isinstance(m, dict): return None if "over" in m: items = list(m.get("over") or []) else: f = m.get("from") or {} up = nodes.get(str(f.get("node") or "")) if not up or str(up.get("status")) != "done": return None pick = str(f.get("pick") or "artifacts") if pick == "evidence": items = _parse_list(up.get("evidence")) elif pick == "children": items = list(up.get("children") or []) else: items = list(up.get("artifacts") or []) cap = int(m.get("max_items") or 0) if cap > 0: items = items[:cap] return items def _child_domain(m: dict, i: int, item) -> list: cd = m.get("child_domain") if not cd: return [] if isinstance(cd, str): cd = [cd] out = [] for x in cd: s = str(x).replace("{i}", str(i)).replace("{item}", _as_text(item)) out.append(s) return _norm_dom(out) def _expand_one(g: dict, nodes: dict, nid: str, items: list) -> dict: n = nodes[nid] if items and int((n.get("map") or {}).get("max_items") or 0) > 0: items = items[:int((n.get("map") or {}).get("max_items"))] n["expanded"] = True n["expanded_at"] = _now() n["item_count"] = len(items) if not items: # N=0 ⇒ 无需执行,父节点直接完成 n["status"] = "done" n["children"] = [] n["evidence"] = n.get("evidence") or "展开 0 项,无需执行" return {"node": nid, "event": "expand", "count": 0, "children": [], "to": "done"} kids = [] for i, it in enumerate(items, 1): cid = "%s#%d" % (nid, i) if cid in nodes: raise SystemExit("子节点 id 已被占用:%s(父节点 %s)" % (cid, nid)) title = "%s[%d]" % (str(n.get("title") or nid), i) if not isinstance(it, (dict, list)): title = "%s %s" % (title, str(it)[:40]) nodes[cid] = { "title": title, "deps": _deps(n), "domain": _child_domain(n.get("map") or {}, i, it), "accepts": list(n.get("accepts") or []), "status": "pending", "evidence": "", "artifacts": [], "attempts": 0, "parent": nid, "index": i, "item": it, } kids.append(cid) n["status"] = "gating" n["children"] = kids return {"node": nid, "event": "expand", "count": len(kids), "children": kids, "to": "gating"} def _expand_maps(g: dict, acts: list) -> int: nodes = g.get("nodes") or {} changed = 0 for nid, n in list(nodes.items()): st = str(n.get("status")) if st not in ("pending", "blocked") or n.get("expanded") or not n.get("map"): continue if st == "blocked": continue # 失败节点不自动展开,等人工 start/重试 okd, _why = _deps_ok(nodes, n) if not okd: continue items = _map_items(g, nodes, nid) if items is None: continue acts.append(_expand_one(g, nodes, nid, items)) changed += 1 return changed def _complete_parents(g: dict, acts: list) -> int: nodes = g.get("nodes") or {} changed = 0 for nid, n in list(nodes.items()): if str(n.get("status")) != "gating": continue kids = [c for c in (n.get("children") or []) if c in nodes] if not kids or not all(_st(nodes, c) in SETTLED for c in kids): continue arts = [] for c in kids: arts.extend(list((nodes.get(c) or {}).get("artifacts") or [])) if arts: n["artifacts"] = arts if all(_is_done(nodes, c) for c in kids): n["status"] = "done" n["completed_at"] = _now() n["evidence"] = n.get("evidence") or ("展开 %d 项全部完成" % len(kids)) acts.append({"node": nid, "event": "children_done", "to": "done", "count": len(kids)}) else: # 子节点全定下来了,但不全成功 ⇒ 父节点不算成功 bad = ["%s(%s)" % (c, _st(nodes, c)) for c in kids if not _is_done(nodes, c)] n["status"] = "blocked" n["blocked_by"] = "child_failed" n["blocked_reason"] = "子节点未全部成功:%s" % ", ".join(bad) acts.append({"node": nid, "event": "children_failed", "to": "blocked", "count": len(kids), "bad": bad}) changed += 1 return changed # ── 条件分支(when / on_dep_fail)─────────────────────────────────────── def _apply_conditions(g: dict, acts: list) -> int: """把「条件不成立 ⇒ skipped」「上游跑不成且要求 block ⇒ blocked」落到状态上。""" nodes = g.get("nodes") or {} changed = 0 for nid, n in list(nodes.items()): if str(n.get("status")) != "pending": continue if n.get("map") and not n.get("expanded"): continue if n.get("subgraph") and not n.get("expanded"): continue v = _dep_verdict(nodes, n) if v["verdict"] == "skip": n["status"] = "skipped" n["skipped_at"] = _now() n["skipped_reason"] = v["why"] acts.append({"node": nid, "event": "skip", "to": "skipped", "why": v["why"]}) changed += 1 elif v["verdict"] == "block": n["status"] = "blocked" n["blocked_by"] = "dep" n["blocked_reason"] = v["why"] acts.append({"node": nid, "event": "block", "to": "blocked", "why": v["why"]}) changed += 1 return changed # ── 子图(节点内部再拆子流程)─────────────────────────────────────────── def _expand_subgraphs(g: dict, acts: list) -> int: nodes = g.get("nodes") or {} changed = 0 for nid, n in list(nodes.items()): if str(n.get("status")) != "pending" or n.get("expanded") or not n.get("subgraph"): continue if not _deps_ok(nodes, n)[0]: continue acts.append(_expand_subgraph(g, nodes, nid)) changed += 1 return changed def _expand_subgraph(g: dict, nodes: dict, nid: str) -> dict: """内联子图 ⇒ 子节点 `<父>/<子id>`,父节点转 gating,子节点全成功才 done。""" n = nodes[nid] sub = n.get("subgraph") or {} specs = [s for s in (sub.get("nodes") or []) if isinstance(s, dict)] prefix = nid + "/" local, kids = {}, [] for i, s in enumerate(specs, 1): raw = str(s.get("id") or ("S%d" % i)).strip() cid = prefix + raw if cid in nodes: raise SystemExit("子图节点 id 已被占用:%s" % cid) local[raw] = cid kids.append((cid, s)) n["expanded"] = True n["expanded_at"] = _now() if not kids: # 空子图 ⇒ 无需执行 n["status"] = "done" n["children"] = [] n["evidence"] = n.get("evidence") or "子图为空,无需执行" return {"node": nid, "event": "expand", "kind": "subgraph", "count": 0, "to": "done"} base = _norm_dom(n.get("domain")) or _norm_dom(sub.get("child_domain")) outer = _deps(n) for cid, s in kids: inner = [] for d in (s.get("deps") or []): tgt = local.get(str(d)) or str(d) if tgt not in inner: inner.append(tgt) deps = list(outer) + [d for d in inner if d not in outer] dom = _norm_dom(s.get("domain")) if not dom: cd = sub.get("child_domain") dom = _norm_dom(str(cd).replace("{id}", cid[len(prefix):])) if cd else list(base) kid = {"title": str(s.get("title") or cid), "deps": deps, "domain": dom, "accepts": [str(x) for x in (s.get("accepts") or [])], "status": "pending", "evidence": "", "artifacts": [], "attempts": 0, "parent": nid, "sub": True} for k in ("deps_rule", "on_dep_fail", "approval", "lease_sec", "when"): if s.get(k) not in (None, "", []): kid[k] = s[k] if s.get("retry"): kid["retry"] = _retry_cfg({"meta": {}}, {"retry": s["retry"]}) nodes[cid] = kid n["status"] = "gating" n["children"] = [c for c, _ in kids] return {"node": nid, "event": "expand", "kind": "subgraph", "count": len(kids), "children": n["children"], "to": "gating"} # ── 按域分池 ──────────────────────────────────────────────────────────── def _pool_caps(g: dict) -> list: p = _meta(g).get("pools") out = [] if isinstance(p, dict): for k, v in p.items(): try: cap = int(v) except (TypeError, ValueError): continue dom = _norm_dom(k) if cap > 0 and dom: out.append((dom[0], cap)) return out def _pool_of(g: dict, n: dict) -> list: dom = _norm_dom((n or {}).get("domain")) if not dom: return [] return [pre for pre, _c in _pool_caps(g) if _dom_overlap(dom, [pre])] def _pool_full(g: dict, n: dict, occupied: list) -> dict: """节点所在池是否已满;满了返回 {"pool","cap","used"},否则 {}。""" caps = dict(_pool_caps(g)) for pre in _pool_of(g, n): cap = caps.get(pre) or 0 if cap <= 0: continue used = sum(1 for x in occupied if pre in _pool_of(g, x)) if used >= cap: return {"pool": pre, "cap": cap, "used": used} return {} def _advance(g: dict) -> list: """把「超时回收/子节点收敛/展开/条件判定」推到不动点;返回动了哪些(供写事件)。""" acts: list = [] for _ in range(6): moved = 0 moved += _reclaim_leases(g, acts) moved += _complete_parents(g, acts) moved += _expand_maps(g, acts) moved += _expand_subgraphs(g, acts) moved += _apply_conditions(g, acts) if not moved: break return acts def _maybe_advance(g: dict) -> bool: """便宜的预判:没有可推进的事就不写盘(保持 ready 的只读语义)。""" nodes = g.get("nodes") or {} lc = _lease_cfg(g) now = time.time() if lc["on_timeout"] != "keep" and lc["timeout_sec"] > 0: for n in nodes.values(): if _lease_over(g, n, now) is not None: return True for nid, n in nodes.items(): st = str(n.get("status")) if st == "gating": kids = [c for c in (n.get("children") or []) if c in nodes] if kids and all(_st(nodes, c) in SETTLED for c in kids): return True elif st == "pending": if n.get("expanded"): continue if n.get("map"): if _deps_ok(nodes, n)[0] and _map_items(g, nodes, nid) is not None: return True continue if n.get("subgraph"): if _deps_ok(nodes, n)[0]: return True continue v = _dep_verdict(nodes, n) if v["verdict"] in ("skip", "block"): return True return False def _advance_graph(ws: str, gid: str, g: dict): acts: list = [] if not _maybe_advance(g): return g, acts def _mut(gg): acts.extend(_advance(gg)) g2 = _update(ws, gid, _mut) for x in acts: _event(ws, gid, x) return g2, acts # ── 就绪集 ────────────────────────────────────────────────────────────── def _ready(g: dict) -> dict: nodes = g.get("nodes") or {} cap = int(_meta(g).get("max_parallel") or 3) running = [k for k, n in nodes.items() if str(n.get("status")) == "running"] ready, waiting = [], [] now = time.time() for nid, n in nodes.items(): st = str(n.get("status")) if st == "gating": kids = [c for c in (n.get("children") or []) if c in nodes] left = [c for c in kids if not _is_done(nodes, c)] waiting.append({"node": nid, "why": "等待 %d 个子节点完成(还剩 %d)" % (len(kids), len(left))}) continue if st != "pending": continue if n.get("map") and not n.get("expanded"): waiting.append({"node": nid, "why": "展开源未就绪(上游没完成或没有产物)"}) continue nb = n.get("not_before") if nb not in (None, ""): try: left = float(nb) - now except (TypeError, ValueError): left = 0 if left > 0: waiting.append({"node": nid, "why": "重试等待中:还需 %d 秒" % int(math.ceil(left))}) continue okd, why = _deps_ok(nodes, n) if not okd: waiting.append({"node": nid, "why": why}) continue dom = _norm_dom(n.get("domain")) clash = [r for r in running if dom and _norm_dom((nodes.get(r) or {}).get("domain")) and _dom_overlap(dom, _norm_dom((nodes.get(r) or {}).get("domain")))] if clash: waiting.append({"node": nid, "why": "域与在跑的冲突:%s" % ", ".join(clash)}) continue pf = _pool_full(g, n, [nodes.get(r) or {} for r in running]) if pf: waiting.append({"node": nid, "why": "域池 %s 已满(%d/%d)" % (pf["pool"], pf["used"], pf["cap"])}) continue ready.append(nid) slots = max(0, cap - len(running)) picked, backlog = [], [] for nid in ready: if len(picked) >= slots: backlog.append(nid) waiting.append({"node": nid, "why": "并发上限 %d 已占满" % cap}) continue dom = _norm_dom((nodes.get(nid) or {}).get("domain")) clash = [p for p in picked if dom and _norm_dom((nodes.get(p) or {}).get("domain")) and _dom_overlap(dom, _norm_dom((nodes.get(p) or {}).get("domain")))] if clash: # 同批也不能撞域:check 已警告「永不同批」,ready 必须兑现 waiting.append({"node": nid, "why": "域与本批已选节点重叠:%s" % ", ".join(clash)}) continue pf = _pool_full(g, nodes.get(nid) or {}, [nodes.get(r) or {} for r in running] + [nodes.get(p) or {} for p in picked]) if pf: waiting.append({"node": nid, "why": "域池 %s 已满(%d/%d)" % (pf["pool"], pf["used"], pf["cap"])}) continue picked.append(nid) awaiting = [k for k, n in nodes.items() if str(n.get("status")) == "awaiting"] return {"gid": g.get("gid"), "running": running, "ready": picked, "backlog": backlog, "waiting": waiting, "awaiting": awaiting, "pools": dict(_pool_caps(g)), "cap": cap, "slots": slots} def cmd_ready(a) -> int: g = _load(a.ws, a.gid) g, acts = _advance_graph(a.ws, a.gid, g) r = _ready(g) r["reclaimed"] = [x for x in acts if x.get("event") == "lease_timeout"] r["expanded"] = [x for x in acts if x.get("event") == "expand"] r["completed_parents"] = [x["node"] for x in acts if x.get("event") == "children_done"] r["lease_notices"] = _lease_notices(g) _out(r) return 0 # ── 状态流转 ──────────────────────────────────────────────────────────── def cmd_start(a) -> int: def _mut(g): n = (g.get("nodes") or {}).get(a.node) if not n: raise SystemExit("没有这个节点:%s" % a.node) st = str(n.get("status")) if st == "running": raise SystemExit("节点 %s 已在跑,不能重复开工" % a.node) if st == "done": raise SystemExit("节点 %s 已完成,不能开工" % a.node) if st == "gating" or (n.get("map") and not n.get("expanded")): raise SystemExit("节点 %s 是展开节点,不能直接开工(由 ready 展开后跑子节点)" % a.node) # pending 与 blocked 都可开工:blocked 是「这轮没过」,重试时 attempts 保留 n["status"] = "running" now = time.time() n["lease_at"] = _iso(now) n["lease_at_ts"] = now if a.lease_sec is not None: n["lease_sec"] = float(a.lease_sec) n["starts"] = int(n.get("starts") or 0) + 1 n.pop("not_before", None) n.pop("retry_at", None) g = _update(a.ws, a.gid, _mut) _event(a.ws, a.gid, {"node": a.node, "to": "running", "by": a.by or ""}) n = (g.get("nodes") or {}).get(a.node) or {} _out({"ok": True, "node": a.node, "status": "running", "lease_sec": _node_lease_sec(g, n)}) return 0 def cmd_report(a) -> int: if a.state not in ("done", "blocked"): raise SystemExit("--state 只认 done / blocked") retry_info = {} def _mut(g): n = (g.get("nodes") or {}).get(a.node) if not n: raise SystemExit("没有这个节点:%s" % a.node) if n.get("map") or str(n.get("status")) == "gating": raise SystemExit("节点 %s 是展开父节点,本身不回报(子节点全完成自动 done)" % a.node) if a.evidence: n["evidence"] = a.evidence for art in (a.artifact or []): n.setdefault("artifacts", []).append(str(art)) for k in ("lease_at", "lease_at_ts", "lease_sec"): n.pop(k, None) if a.state == "blocked": n["attempts"] = int(n.get("attempts") or 0) + 1 n["blocked_by"] = "report" rc = _retry_cfg(g, n) left = rc["max"] - n["attempts"] + 1 # 还能再试几次 if rc["max"] > 0 and n["attempts"] <= rc["max"]: delay = _delay_of(rc, n["attempts"]) at = time.time() + delay n["status"] = "pending" n["not_before"] = at n["retry_at"] = _iso(at) n["retry_exhausted"] = False retry_info.update({"policy_max": rc["max"], "attempts": n["attempts"], "requeued": True, "delay_sec": delay, "retry_at": n["retry_at"], "retries_left": max(0, left - 1)}) else: n["status"] = "blocked" n["retry_exhausted"] = rc["max"] > 0 n.pop("not_before", None) n.pop("retry_at", None) retry_info.update({"policy_max": rc["max"], "attempts": n["attempts"], "requeued": False, "retry_at": None, "exhausted": True}) else: n.pop("not_before", None) n.pop("retry_at", None) if n.get("approval") and not n.get("approved_at"): n["status"] = "awaiting" # 跑完了,等人审过目 n["awaiting_at"] = _now() else: n["status"] = "done" n["done_at"] = _now() g = _update(a.ws, a.gid, _mut) final = _st(g.get("nodes") or {}, a.node) _event(a.ws, a.gid, {"node": a.node, "to": final if final == "awaiting" else (a.state if a.state == "done" else "blocked"), "evidence": a.evidence or "", "by": a.by or "", "artifacts": list(a.artifact or [])}) g, acts = _advance_graph(a.ws, a.gid, g) r = _ready(g) _out({"ok": True, "node": a.node, "status": str((g.get("nodes") or {}).get(a.node, {}).get("status")), "next_ready": r["ready"], "retry": retry_info or None, "completed_parents": [x["node"] for x in acts if x.get("event") == "children_done"]}) return 0 def cmd_expand(a) -> int: acts = [] def _mut(g): nodes = g.get("nodes") or {} n = nodes.get(a.node) if not n: raise SystemExit("没有这个节点:%s" % a.node) if not n.get("map"): raise SystemExit("节点 %s 没有 map 声明,无可展开" % a.node) if n.get("expanded"): raise SystemExit("节点 %s 已展开过(children=%s)" % (a.node, n.get("children"))) okd, why = _deps_ok(nodes, n) if not okd and not a.force: raise SystemExit("展开源未就绪:%s(要强制展开加 --force)" % why) items = _map_items(g, nodes, a.node) if items is None: raise SystemExit("展开源未就绪:上游节点没完成或没有产物") acts.append(_expand_one(g, nodes, a.node, items)) g = _update(a.ws, a.gid, _mut) for x in acts: _event(a.ws, a.gid, x) g, more = _advance_graph(a.ws, a.gid, g) acts.extend(more) n = (g.get("nodes") or {}).get(a.node) or {} _out({"ok": True, "node": a.node, "status": n.get("status"), "children": n.get("children") or [], "count": len(n.get("children") or [])}) return 0 # ── 人审 / 取消 / 派活 / 排期 / 进度 ───────────────────────────────────── def cmd_approve(a) -> int: """人审节点:`awaiting` ⇒ done(默认)或 blocked(--reject)。""" def _mut(g): n = (g.get("nodes") or {}).get(a.node) if not n: raise SystemExit("没有这个节点:%s" % a.node) st = str(n.get("status")) if st != "awaiting": raise SystemExit("节点 %s 不在等人审(当前 %s)" % (a.node, st)) if a.reject: n["status"] = "blocked" n["blocked_by"] = "rejected" n["blocked_reason"] = a.reason or "人审驳回" n["rejected_at"] = _now() else: n["status"] = "done" n["done_at"] = _now() n["approved_at"] = _now() n["approved_by"] = a.by or "user" g = _update(a.ws, a.gid, _mut) st = _st(g.get("nodes") or {}, a.node) _event(a.ws, a.gid, {"node": a.node, "to": st, "by": a.by or "", "reject": bool(a.reject), "reason": a.reason or ""}) g, _acts = _advance_graph(a.ws, a.gid, g) r = _ready(g) _out({"ok": True, "node": a.node, "status": _st(g.get("nodes") or {}, a.node), "next_ready": r["ready"]}) return 0 def cmd_cancel(a) -> int: """取消节点(连坐它的子节点);`--all` 取消整图。""" if not a.node and not a.all: raise SystemExit("要给 --node <节点> 或 --all") hit: list = [] def _mut(g): nodes = g.get("nodes") or {} if a.node and a.node not in nodes: raise SystemExit("没有这个节点:%s" % a.node) stack = [a.node] if a.node else list(nodes) while stack: nid = stack.pop() n = nodes.get(nid) if not n or str(n.get("status")) in ("done", "cancelled"): continue n["status"] = "cancelled" n["cancelled_at"] = _now() if a.by: n["cancelled_by"] = a.by if a.reason: n["cancel_reason"] = a.reason hit.append(nid) stack.extend(n.get("children") or []) g = _update(a.ws, a.gid, _mut) for nid in hit: _event(a.ws, a.gid, {"node": nid, "to": "cancelled", "by": a.by or "", "reason": a.reason or ""}) g, acts = _advance_graph(a.ws, a.gid, g) r = _ready(g) _out({"ok": True, "cancelled": hit, "skipped_by_cancel": [x["node"] for x in acts if x.get("event") == "skip"], "next_ready": r["ready"]}) return 0 def cmd_tick(a) -> int: """自动派活:推进到不动点 → 算就绪集 → 直接把就绪节点开成 running。""" started, missed = [], [] g = _load(a.ws, a.gid) g, acts = _advance_graph(a.ws, a.gid, g) r = _ready(g) cand = list(r["ready"]) if a.limit: cand = cand[:int(a.limit)] if cand and not a.dry_run: def _mut(gg): nodes = gg.get("nodes") or {} for nid in cand: n = nodes.get(nid) if not n or str(n.get("status")) != "pending": missed.append(nid) continue if _dep_verdict(nodes, n)["verdict"] != "go": missed.append(nid) continue now = time.time() n["status"] = "running" n["lease_at"] = _iso(now) n["lease_at_ts"] = now if a.lease_sec is not None: n["lease_sec"] = float(a.lease_sec) n["starts"] = int(n.get("starts") or 0) + 1 n.pop("not_before", None) n.pop("retry_at", None) n["started_by"] = a.by or "tick" started.append(nid) g = _update(a.ws, a.gid, _mut) for nid in started: _event(a.ws, a.gid, {"node": nid, "to": "running", "by": a.by or "tick"}) g, more = _advance_graph(a.ws, a.gid, g) acts.extend(more) r2 = _ready(g) _out({"gid": a.gid, "started": started, "missed": missed, "dry_run": bool(a.dry_run), "advanced": acts, "ready_now": r2["ready"], "running": r2["running"], "awaiting": r2["awaiting"], "cap": r2["cap"], "slots": r2["slots"]}) return 0 def cmd_plan(a) -> int: """出一行排期(四条硬规则已按规范形态写好),⛔ 不写宿主库。""" ws = str(Path(a.ws).resolve()).replace("\\", "/") g = _load(a.ws, a.gid) r = _ready(g) after = 240 if a.after_sec is None else int(a.after_sec) at_ms = int((time.time() + after) * 1000) scope = a.scope or (ws.rstrip("/").rsplit("/", 1)[-1] + "/") row = { "name": "[图模式派活]-%s-%s" % (a.gid, time.strftime("%Y%m%d-%H%M")), "prompt": ("跑一次图模式派活:python %s tick --ws %s --gid %s --by tick;" "本轮只做这一件事,做完即停。" % (Path(__file__).resolve(), ws, a.gid)), "cwds": ws, "workspace_scope": scope, "next_run_at": at_ms, "scheduled_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(at_ms / 1000.0)), } _out({"ok": True, "gid": a.gid, "rows": [row], "ready": r["ready"], "running": r["running"], "rules": {"cwds": "正斜杠绝对路径", "workspace_scope": "线名/", "next_run_at": "毫秒 epoch", "scheduled_at": "ISO 本地时间"}, "note": "只产出行,⛔ 不写宿主库(图模式零侵入)"}) return 0 def _remaining_chain(nodes: dict) -> list: """未完成节点之间的最长依赖链(关键路径)。""" todo = {k: v for k, v in nodes.items() if str(v.get("status")) not in ("done", "skipped", "cancelled")} memo = {} def best(nid): if nid in memo: return memo[nid] memo[nid] = [nid] cand = [nid] for d in _deps(todo[nid]): if d in todo: chain = best(d) + [nid] if len(chain) > len(cand): cand = chain memo[nid] = cand return cand out: list = [] for nid in todo: c = best(nid) if len(c) > len(out): out = c return out def cmd_progress(a) -> int: g = _load(a.ws, a.gid) g, acts = _advance_graph(a.ws, a.gid, g) nodes = g.get("nodes") or {} cnt: dict = {} for n in nodes.values(): s = str(n.get("status")) cnt[s] = cnt.get(s, 0) + 1 active = len(nodes) - cnt.get("cancelled", 0) - cnt.get("skipped", 0) done = cnt.get("done", 0) percent = round(100.0 * done / active, 1) if active else 100.0 r = _ready(g) cp = _remaining_chain(nodes) _out({"gid": g.get("gid"), "title": g.get("title"), "total": len(nodes), "by_status": cnt, "active": active, "done": done, "percent": percent, "running": r["running"], "ready": r["ready"], "awaiting": r["awaiting"], "blocked": [k for k, n in nodes.items() if str(n.get("status")) == "blocked"], "skipped": [k for k, n in nodes.items() if str(n.get("status")) == "skipped"], "cancelled": [k for k, n in nodes.items() if str(n.get("status")) == "cancelled"], "critical_path": cp, "critical_len": len(cp), "advanced": acts}) return 0 # ── 建图 ──────────────────────────────────────────────────────────────── def _gid_of(title: str) -> str: safe = "".join(c for c in title if c not in '\\/:*?"<>|\r\n\t')[:24].strip() return "%s-%s" % (time.strftime("%Y%m%d-%H%M"), safe or "graph") def _norm_map(m) -> dict: if not isinstance(m, dict): raise SystemExit("map 必须是对象") out = {} if "over" in m: if not isinstance(m["over"], list): raise SystemExit("map.over 必须是列表") out["over"] = list(m["over"]) if "from" in m: f = m["from"] if not isinstance(f, dict) or not str(f.get("node") or "").strip(): raise SystemExit('map.from 需要 {"node": "上游节点"} 形态') out["from"] = {"node": str(f["node"]).strip(), "pick": str(f.get("pick") or "artifacts")} if not out: raise SystemExit("map 需要 over(建图给列表)或 from(运行时读上游)") if m.get("child_domain"): out["child_domain"] = m["child_domain"] if m.get("max_items"): out["max_items"] = int(m["max_items"]) if m.get("as"): out["as"] = str(m["as"]) return out def cmd_init(a) -> int: if a.nodes_json: raw = sys.stdin.read() if a.nodes_json == "-" else \ Path(a.nodes_json).read_text(encoding="utf-8") spec = json.loads(raw) elif a.kpi: spec = {"nodes": [ {"id": "N%d" % (i + 1), "title": k.strip(), "deps": [], "domain": [], "accepts": [k.strip()]} for i, k in enumerate([x for x in a.kpi.replace(";", ";").split(";") if x.strip()]) ]} else: raise SystemExit("要给 --kpi 或 --nodes-json") if a.on_timeout not in TIMEOUT_POLICIES: raise SystemExit("--on-timeout 只认 %s" % " / ".join(TIMEOUT_POLICIES)) if a.retry_backoff not in BACKOFFS: raise SystemExit("--retry-backoff 只认 %s" % " / ".join(BACKOFFS)) nodes = {} for i, n in enumerate(spec.get("nodes") or []): raw = n.get("id") nid = str(raw).strip() if raw not in (None, "") else "" if not nid: k = i + 1 while ("N%d" % k) in nodes: # 自动编号要避开已占用的 id k += 1 nid = "N%d" % k if nid in nodes: # 显式重复 ⇒ 硬错,⛔ 不静默覆盖前一个 raise SystemExit("节点 id 重复:%s" % nid) rule = str(n.get("deps_rule") or a.deps_rule or "all_success") if rule not in DEP_RULES: raise SystemExit("%s 的 deps_rule 非法:%r(只认 %s)" % (nid, rule, " / ".join(DEP_RULES))) nodes[nid] = { "title": str(n.get("title") or nid), "deps": [str(x) for x in (n.get("deps") or [])], "domain": _norm_dom(n.get("domain")), "accepts": [str(x) for x in (n.get("accepts") or [])], "status": "pending", "evidence": "", "artifacts": [], "attempts": 0, "deps_rule": rule, } if n.get("retry"): if not isinstance(n["retry"], dict): raise SystemExit("%s 的 retry 必须是对象" % nid) nodes[nid]["retry"] = _retry_cfg({"meta": {}}, {"retry": n["retry"]}) if n.get("lease_sec") not in (None, ""): nodes[nid]["lease_sec"] = float(n["lease_sec"]) if n.get("on_timeout"): if str(n["on_timeout"]) not in TIMEOUT_POLICIES: raise SystemExit("%s 的 on_timeout 非法:%r" % (nid, n["on_timeout"])) nodes[nid]["on_timeout"] = str(n["on_timeout"]) if n.get("map"): nodes[nid]["map"] = _norm_map(n["map"]) if n.get("subgraph"): sg = n["subgraph"] if not isinstance(sg, dict) or not isinstance(sg.get("nodes"), list): raise SystemExit('%s 的 subgraph 要写 {"nodes": [...]}' % nid) if n.get("map"): raise SystemExit("%s 同时声明了 map 与 subgraph(只能选一个)" % nid) sub = {"nodes": [dict(s) for s in sg["nodes"] if isinstance(s, dict)]} if sg.get("child_domain"): sub["child_domain"] = str(sg["child_domain"]) nodes[nid]["subgraph"] = sub if n.get("when"): if not isinstance(n["when"], list): raise SystemExit("%s 的 when 必须是列表" % nid) clauses = [] for c in n["when"]: if not isinstance(c, dict) or not str(c.get("node") or "").strip(): raise SystemExit('%s 的 when 子句要写 {"node": "A", "status": "done"}' % nid) clauses.append({"node": str(c["node"]).strip(), "status": str(c.get("status") or "done")}) nodes[nid]["when"] = clauses if n.get("on_dep_fail"): if str(n["on_dep_fail"]) not in ON_DEP_FAIL: raise SystemExit("%s 的 on_dep_fail 非法:%r(只认 %s)" % (nid, n["on_dep_fail"], " / ".join(ON_DEP_FAIL))) nodes[nid]["on_dep_fail"] = str(n["on_dep_fail"]) if n.get("approval"): nodes[nid]["approval"] = True gid = a.gid or _gid_of(a.title) g = {"gid": gid, "title": a.title, "created_at": _now(), "meta": {"max_parallel": int(spec.get("max_parallel") or a.max_parallel or 3), "lease": {"timeout_sec": float(a.lease_sec) if a.lease_sec is not None else DEFAULT_LEASE_SEC, "on_timeout": a.on_timeout}, "retry": {"max": int(a.retry_max or 0), "delay_sec": float(a.retry_delay or 0), "backoff": a.retry_backoff, "factor": float(a.retry_factor or 2)}}, "nodes": nodes} d = _gdir(a.ws, gid) pools = {} if isinstance(spec.get("pools"), dict): pools.update(spec["pools"]) for item in (a.pool or []): s = str(item) if "=" not in s: raise SystemExit("--pool 要写成 域前缀=上限(如 docs/ui/=1)") k, v = s.split("=", 1) pools[k.strip()] = v if pools: g["meta"]["pools"] = {} for k, v in pools.items(): dom = _norm_dom(k) if not dom: raise SystemExit("域池的域不能为空") try: cap = int(v) except (TypeError, ValueError): raise SystemExit("域池上限不是整数:%s=%r" % (k, v)) if cap <= 0: raise SystemExit("域池上限必须 ≥1:%s=%r" % (k, v)) g["meta"]["pools"][dom[0]] = cap d.mkdir(parents=True, exist_ok=True) _save(a.ws, gid, g) _event(a.ws, gid, {"event": "init", "nodes": list(nodes)}) _out({"ok": True, "gid": gid, "dir": str(d), "nodes": len(nodes), "note": "独立图:⛔ 不参与现有机制(不写 goal.json / tasks.json)"}) return 0 def cmd_show(a) -> int: _out(_load(a.ws, a.gid)) return 0 def cmd_list(a) -> int: root = _root(a.ws) rows = [] if root.is_dir(): for d in sorted(root.iterdir()): p = d / "graph.json" if not p.is_file(): continue try: g = json.loads(p.read_text(encoding="utf-8")) except Exception: continue nodes = g.get("nodes") or {} cnt = {} for n in nodes.values(): cnt[str(n.get("status"))] = cnt.get(str(n.get("status")), 0) + 1 rows.append({"gid": g.get("gid"), "title": g.get("title"), "nodes": len(nodes), "by_status": cnt, "created_at": g.get("created_at")}) _out({"ws": str(Path(a.ws).resolve()), "graphs": rows}) return 0 # ── 入口 ──────────────────────────────────────────────────────────────── def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="graphctl", description="图模式独立实现(不碰现有机制)") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("init", help="按判据或节点定义建图") p.add_argument("--ws", required=True) p.add_argument("--title", required=True) p.add_argument("--gid", default="") p.add_argument("--kpi", default="", help="判据,分号分隔;每条生成一个节点") p.add_argument("--nodes-json", default="", help="节点定义文件(- 表示标准输入)") p.add_argument("--max-parallel", type=int, default=3) p.add_argument("--lease-sec", type=float, default=None, help="租约多少秒算超时(默认 3600;0 = 不判超时)") p.add_argument("--on-timeout", default="requeue", help="超时处置:requeue(默认)/block/keep") p.add_argument("--retry-max", type=int, default=0, help="默认自动重试次数(默认 0)") p.add_argument("--retry-delay", type=float, default=0, help="重试基础间隔秒(默认 0)") p.add_argument("--retry-backoff", default="fixed", help="fixed(默认)/linear/exponential") p.add_argument("--retry-factor", type=float, default=2, help="指数退避倍数(默认 2)") p.add_argument("--deps-rule", default="all_success", help="默认依赖规则:all_success(默认)/all_done/one_success") p.add_argument("--pool", action="append", default=[], help="按域分池:域前缀=并发上限,可重复(如 docs/ui/=1)") p.set_defaults(fn=cmd_init) for name, fn in (("check", cmd_check), ("ready", cmd_ready), ("show", cmd_show)): p = sub.add_parser(name) p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.set_defaults(fn=fn) p = sub.add_parser("start", help="节点开工(记租约)") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--node", required=True) p.add_argument("--by", default="") p.add_argument("--lease-sec", type=float, default=None, help="本次开工的租约秒数(覆盖默认)") p.set_defaults(fn=cmd_start) p = sub.add_parser("report", help="节点回报") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--node", required=True) p.add_argument("--state", required=True) p.add_argument("--evidence", default="") p.add_argument("--artifact", action="append", default=[], help="产物清单,可重复(供 map.from pick=artifacts 读取)") p.add_argument("--by", default="") p.set_defaults(fn=cmd_report) p = sub.add_parser("expand", help="手动触发展开") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--node", required=True) p.add_argument("--force", action="store_true") p.set_defaults(fn=cmd_expand) p = sub.add_parser("approve", help="人审通过/驳回(awaiting ⇒ done/blocked)") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--node", required=True) p.add_argument("--by", default="") p.add_argument("--reject", action="store_true") p.add_argument("--reason", default="") p.set_defaults(fn=cmd_approve) p = sub.add_parser("cancel", help="取消节点(连坐子节点)或整图") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--node", default="") p.add_argument("--all", action="store_true") p.add_argument("--by", default="") p.add_argument("--reason", default="") p.set_defaults(fn=cmd_cancel) p = sub.add_parser("tick", help="自动派活:算就绪集并直接开工") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--by", default="tick") p.add_argument("--limit", type=int, default=0, help="本次最多开几个(默认不额外限)") p.add_argument("--dry-run", action="store_true", help="只算不开") p.add_argument("--lease-sec", type=float, default=None) p.set_defaults(fn=cmd_tick) p = sub.add_parser("plan", help="出一行排期(四条硬规则已规范化)") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.add_argument("--scope", default="", help="workspace_scope,默认取工作区目录名") p.add_argument("--after-sec", type=int, default=None, help="多久后触发(默认 240 秒)") p.set_defaults(fn=cmd_plan) p = sub.add_parser("progress", help="进度与关键路径") p.add_argument("--ws", required=True) p.add_argument("--gid", required=True) p.set_defaults(fn=cmd_progress) p = sub.add_parser("list") p.add_argument("--ws", required=True) p.set_defaults(fn=cmd_list) a = ap.parse_args(argv) return a.fn(a) if __name__ == "__main__": sys.exit(main())