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 为空
This commit is contained in:
1 parent
42a130e4f2
commit
3b5bcc535a
3 files changed
+1265
-82
No files matched your search
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""graphctl.py —— 「图模式」的独立实现(2026-10-18)。
|
||||
"""graphctl.py —— 「图模式」的独立实现(2026-10-18;2026-10-19 补批量能力)。
|
||||
|
||||
## 它是什么
|
||||
|
||||
@@ -19,49 +19,66 @@
|
||||
"gid": "20261018-2230-ui改造",
|
||||
"title": "给三块功能各加一份文档",
|
||||
"created_at": "2026-10-18T22:30:00",
|
||||
"meta": {"max_parallel": 3},
|
||||
"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": "", "attempts": 0}
|
||||
"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`。
|
||||
`status` 五态:`pending` → `running` → `done` / `blocked`;
|
||||
另有 `gating`——展开出来的父节点,等子节点全部完成(父节点自动转 `done`)。
|
||||
|
||||
## 命令
|
||||
|
||||
init --ws <工作区> --title <目标> (--kpi "判据A;判据B" | --nodes-json <文件|->)
|
||||
check --ws <ws> --gid <gid> 环/悬空依赖/空域/同批域冲突
|
||||
ready --ws <ws> --gid <gid> 就绪集(含为什么没被选)
|
||||
start --ws <ws> --gid <gid> --node N1
|
||||
report --ws <ws> --gid <gid> --node N1 --state done|blocked [--evidence "..."]
|
||||
[--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>
|
||||
|
||||
## 就绪的判据(四条全中)
|
||||
## 就绪的判据
|
||||
|
||||
① 依赖全部 done ② 自己还是 pending
|
||||
① 依赖按 `deps_rule` 满足(默认 all_success)
|
||||
② 自己还是 pending,且不在重试等待窗内(`not_before` 未到 ⇒ 等)
|
||||
③ 域不与任何 running 节点重叠,也不与本批已选节点重叠
|
||||
④ 不超 max_parallel(running 占名额)
|
||||
|
||||
## 状态的走法
|
||||
## 三件批量能力(2026-10-19 补)
|
||||
|
||||
pending --start--> running --report done--> done
|
||||
|--report blocked--> blocked --start--> running(重试,attempts 已累加)
|
||||
|
||||
`start` 只拒 `running`(已在跑)与 `done`(已完成);`blocked` 可重开。
|
||||
租约: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
|
||||
@@ -69,7 +86,12 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
MARK = ".workbuddy/graph"
|
||||
STATUSES = ("pending", "running", "done", "blocked")
|
||||
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
|
||||
|
||||
|
||||
# ── 基础设施 ────────────────────────────────────────────────────────────
|
||||
@@ -77,6 +99,26 @@ 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
|
||||
|
||||
@@ -101,36 +143,110 @@ def _save(ws: str, gid: str, g: dict) -> None:
|
||||
os.replace(tmp, p) # 原子替换,避免半截文件
|
||||
|
||||
|
||||
def _update(ws: str, gid: str, mutate, retries: int = 3) -> dict:
|
||||
"""读-改-写,带版本号乐观重试(2026-10-19 修两处实测缺陷)。
|
||||
class _Lock:
|
||||
"""写串行化:`os.mkdir` 是原子的,用它当互斥量(⛔ 不用 OS 文件锁,本机会卡死)。
|
||||
|
||||
① 临时名原来固定成 `graph.json.tmp` ⇒ 两进程同时写直接撞 `PermissionError`
|
||||
(桌面测试报告第 14 条实测:同时对 C、D 执行 start 就复现)。
|
||||
② 更隐蔽的一层:就算临时名不撞,两人同时读-改-写会**丢更新** ——
|
||||
后写的整份覆盖前写的,节点状态静默消失,连报错都没有。
|
||||
③ 对策:图里带 `_rev`,每次在**最新磁盘状态**上重放变更,写完回读核对;
|
||||
被别的进程覆盖就重读重做,最多 `retries` 次,仍失败则**报错退出**(⛔ 不静默)。
|
||||
⚠️ 本机不用 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)
|
||||
for _ in range(retries):
|
||||
g = json.loads(p.read_text(encoding="utf-8"))
|
||||
rev = int(g.get("_rev") or 0)
|
||||
mutate(g)
|
||||
g["_rev"] = rev + 1
|
||||
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)
|
||||
try:
|
||||
back = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
back = {}
|
||||
if int(back.get("_rev") or 0) == rev + 1:
|
||||
return back
|
||||
raise SystemExit("并发冲突:%s 连续 %d 次被别的进程覆盖,稍后重试" % (p, retries))
|
||||
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:
|
||||
@@ -176,6 +292,79 @@ 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 = {}, [], []
|
||||
@@ -215,12 +404,30 @@ def cmd_check(a) -> int:
|
||||
for d in _deps(n):
|
||||
if d not in nodes:
|
||||
errs.append("%s 的依赖 %s 不存在" % (nid, d))
|
||||
if not _norm_dom(n.get("domain")):
|
||||
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 []):
|
||||
if not (n.get("accepts") or []) and not n.get("map") and not n.get("parent"):
|
||||
warns.append("%s 没有验收判据 ⇒ 判不出完成" % nid)
|
||||
|
||||
ids = list(nodes)
|
||||
@@ -237,21 +444,295 @@ def cmd_check(a) -> int:
|
||||
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((g.get("meta") or {}).get("max_parallel") or 3)
|
||||
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
|
||||
nd = _deps(n)
|
||||
lack = [d for d in nd if not _is_done(nodes, d)]
|
||||
if lack:
|
||||
waiting.append({"node": nid, "why": "依赖未完成:%s" % ", ".join(lack)})
|
||||
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
|
||||
@@ -286,7 +767,13 @@ def _ready(g: dict) -> dict:
|
||||
|
||||
def cmd_ready(a) -> int:
|
||||
g = _load(a.ws, a.gid)
|
||||
_out(_ready(g))
|
||||
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
|
||||
|
||||
|
||||
@@ -296,38 +783,125 @@ def cmd_start(a) -> int:
|
||||
n = (g.get("nodes") or {}).get(a.node)
|
||||
if not n:
|
||||
raise SystemExit("没有这个节点:%s" % a.node)
|
||||
if str(n.get("status")) == "running":
|
||||
st = str(n.get("status"))
|
||||
if st == "running":
|
||||
raise SystemExit("节点 %s 已在跑,不能重复开工" % a.node)
|
||||
if str(n.get("status")) == "done":
|
||||
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"
|
||||
_update(a.ws, a.gid, _mut)
|
||||
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 ""})
|
||||
_out({"ok": True, "node": a.node, "status": "running"})
|
||||
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
|
||||
n["status"] = a.state
|
||||
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,
|
||||
"evidence": a.evidence or "", "by": a.by or ""})
|
||||
_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": a.state,
|
||||
"next_ready": r["ready"]})
|
||||
_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
|
||||
|
||||
|
||||
@@ -337,6 +911,31 @@ def _gid_of(title: str) -> str:
|
||||
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 \
|
||||
@@ -351,6 +950,11 @@ def cmd_init(a) -> int:
|
||||
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")
|
||||
@@ -362,16 +966,40 @@ def cmd_init(a) -> int:
|
||||
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": "", "attempts": 0,
|
||||
"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)},
|
||||
"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)
|
||||
@@ -422,20 +1050,30 @@ def main(argv=None) -> int:
|
||||
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, extra in (("check", cmd_check, None), ("ready", cmd_ready, None),
|
||||
("show", cmd_show, None)):
|
||||
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 = 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="节点回报")
|
||||
@@ -444,9 +1082,18 @@ def main(argv=None) -> int:
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user