Files
workbuddy_skills/session-mechanism/graph/graphctl.py
T
admin 3b5bcc535a feat(session-mechanism/graph): B 方案——租约超时、失败策略与触发规则、动态展开
- 租约与超时:节点 start 记租约时刻,ready 时把租约超时的 running 节点识别出来并按策略处置
  (默认回 pending + attempts+1),卡死的节点不再永远占着并发名额
- 失败策略与下游触发规则:节点级重试(次数/间隔/退避/超限转 blocked);
  依赖触发支持 all_success(默认)/all_done/one_success 三档
- 动态展开(map):支持建图时给列表、也支持运行时从上游产物读 N 项;展开出的子节点同样受
  max_parallel 约束,子节点全部完成父节点才算完成
- 测试 52 → 92 条,实跑 Ran 92 tests OK(68 s),现有用例零回归
- README 补三节:字段怎么写、默认值是什么
- 改动只在 graph/ 目录内;collabd.py/goalctl.py/看板/钩子 diff 为空
2026-10-19 21:11:27 +08:00

1107 lines
44 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""graphctl.py —— 「图模式」的独立实现(2026-10-18;2026-10-19 补批量能力)。
## 它是什么
把目标拆成节点、按依赖推进的**独立**运行模式。**⛔ 不修改任何现有机制**:
不读也不写 `goal.json` / `tasks.json` / `collabd-state.json`,不参与六道闸,
不接管常驻循环,不改变现有会话的任何行为。
## 数据落点(独立目录,与现有状态完全分开)
<ws>/.workbuddy/graph/<gid>/graph.json 图的唯一真源
<ws>/.workbuddy/graph/<gid>/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 <ws> --gid <gid> 环/悬空依赖/空域/同批域冲突/非法规则
ready --ws <ws> --gid <gid> 就绪集(含为什么没被选);顺带回收超时租约、展开 map
start --ws <ws> --gid <gid> --node N1 [--by ...] [--lease-sec S]
report --ws <ws> --gid <gid> --node N1 --state done|blocked [--evidence "..."] [--artifact P ...]
expand --ws <ws> --gid <gid> --node P [--force] 手动触发展开
show --ws <ws> --gid <gid>
list --ws <ws>
## 就绪的判据
① 依赖按 `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")
DEP_RULES = ("all_success", "all_done", "one_success")
TIMEOUT_POLICIES = ("requeue", "block", "keep")
BACKOFFS = ("fixed", "linear", "exponential")
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 _deps_ok(nodes: dict, n: dict):
"""按 `deps_rule` 判依赖是否放行,返回 (bool, 没满足的原因)。"""
deps = _deps(n)
if not deps:
return True, ""
rule = str(n.get("deps_rule") or "all_success")
st = {d: str((nodes.get(d) or {}).get("status")) for d in deps}
if rule == "all_done":
lack = [d for d, s in st.items() if s not in ("done", "blocked")]
return (not lack), ("依赖未跑完:%s" % ", ".join(lack))
if rule == "one_success":
if any(s == "done" for s in st.values()):
return True, ""
return False, ("等待任一上游成功:%s" % ", ".join(
"%s(%s)" % (d, s) for d, s in st.items()))
lack = [d for d, s in st.items() if s != "done"]
return (not lack), ("依赖未完成:%s" % ", ".join("%s(%s)" % (d, st[d]) for d in lack))
# ── 三件批量能力的配置读法 ──────────────────────────────────────────────
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("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)
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(_is_done(nodes, c) for c in kids):
continue
n["status"] = "done"
n["completed_at"] = _now()
arts = []
for c in kids:
arts.extend(list((nodes.get(c) or {}).get("artifacts") or []))
if arts:
n["artifacts"] = arts
n["evidence"] = n.get("evidence") or ("展开 %d 项全部完成" % len(kids))
acts.append({"node": nid, "event": "children_done", "to": "done",
"count": len(kids)})
changed += 1
return changed
def _advance(g: dict) -> list:
"""把「超时回收 / 子节点收敛 / 展开」推到不动点;返回动了哪些(供写事件)。"""
acts: list = []
for _ in range(4):
moved = 0
moved += _reclaim_leases(g, acts)
moved += _complete_parents(g, acts)
moved += _expand_maps(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(_is_done(nodes, c) for c in kids):
return True
elif st == "pending" and n.get("map") and not n.get("expanded"):
if _deps_ok(nodes, n)[0] and _map_items(g, nodes, nid) is not None:
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
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
picked.append(nid)
return {"gid": g.get("gid"), "running": running, "ready": picked,
"backlog": backlog, "waiting": waiting,
"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["status"] = "done"
n["done_at"] = _now()
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": 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 _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"])
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)
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.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("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())