feat(session-mechanism/graph): C 方案——条件分支、子图、人审、取消、分池、自动派活、进度

This commit is contained in:
admin committed 2026-10-19 21:37:51 +08:00
1 parent 3b5bcc535a
commit 0c62e1b350
3 files changed
+1016 -39

No files matched your search

+611 -27
View File
@@ -47,6 +47,24 @@
expand --ws <ws> --gid <gid> --node P [--force] 手动触发展开
show --ws <ws> --gid <gid>
list --ws <ws>
tick --ws <ws> --gid <gid> [--by ...] [--limit N] [--dry-run] 自动派活(算完就开)
approve --ws <ws> --gid <gid> --node N [--reject] [--reason ...] 人审
cancel --ws <ws> --gid <gid> (--node N | --all) [--reason ...] 取消
plan --ws <ws> --gid <gid> [--scope ...] [--after-sec N] 出一行排期
progress --ws <ws> --gid <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/关键路径(未完成节点的最长依赖链)。
## 就绪的判据
@@ -87,10 +105,13 @@ from pathlib import Path
MARK = ".workbuddy/graph"
LOCK_STALE_SEC = 30.0 # 锁目录超过这么久没动静 ⇒ 认定持锁进程已死,接管
STATUSES = ("pending", "running", "done", "blocked", "gating")
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
@@ -292,23 +313,78 @@ 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, 没满足的原因)。"""
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)
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}
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 ("done", "blocked")]
return (not lack), ("依赖未跑完:%s" % ", ".join(lack))
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 True, ""
return False, ("等待任一上游成功:%s" % ", ".join(
"%s(%s)" % (d, s) for d, s in st.items()))
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"]
return (not lack), ("依赖未完成:%s" % ", ".join("%s(%s)" % (d, st[d]) for d in lack))
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"]
# ── 三件批量能力的配置读法 ──────────────────────────────────────────────
@@ -418,6 +494,40 @@ def cmd_check(a) -> int:
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:
@@ -430,6 +540,26 @@ def cmd_check(a) -> int:
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)):
@@ -635,30 +765,170 @@ def _complete_parents(g: dict, acts: list) -> int:
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):
if not kids or not all(_st(nodes, c) in SETTLED 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)})
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(4):
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
@@ -677,10 +947,21 @@ def _maybe_advance(g: dict) -> bool:
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):
if kids and all(_st(nodes, c) in SETTLED 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:
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
@@ -741,6 +1022,11 @@ def _ready(g: dict) -> dict:
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))
@@ -758,10 +1044,19 @@ def _ready(g: dict) -> dict:
# 同批也不能撞域: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,
"backlog": backlog, "waiting": waiting, "awaiting": awaiting,
"pools": dict(_pool_caps(g)),
"cap": cap, "slots": slots}
@@ -852,13 +1147,20 @@ def cmd_report(a) -> int:
"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)
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)
_event(a.ws, a.gid, {"node": a.node, "to": a.state if a.state == "done" else "blocked",
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)
@@ -905,6 +1207,197 @@ def cmd_expand(a) -> int:
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()
@@ -990,6 +1483,34 @@ def cmd_init(a) -> int:
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),
@@ -1002,6 +1523,28 @@ def cmd_init(a) -> int:
"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)})
@@ -1060,6 +1603,8 @@ def main(argv=None) -> int:
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)):
@@ -1094,6 +1639,45 @@ def main(argv=None) -> int:
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)