Files
workbuddy_skills/session-mechanism/graph/README.md
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

15 KiB
Raw Blame History

图模式(独立功能)

2026-10-18 新增;2026-10-19 补「租约超时 / 重试与触发规则 / 动态展开」三件批量能力。 这一套与现有「项目机制」完全并列:不读写 goal.json、tasks.json、collabd-state.json,不参与六道闸,不接管常驻循环,不改变现有任何会话的行为。 停用方式:不调用它就行;它没有常驻进程、没有注册任何钩子、没有改任何现有文件。

一、它解决什么

现有机制是「一棒干一堆、由会话读摘要决定下一棒」。图模式把这层换成:

  • 节点:一块独立的活,带自己的域(允许写哪些目录)和判据。
  • 边:节点之间的依赖;依赖全 done 才放行。
  • 状态:写在一份图上,跨会话共享,不依赖任何会话的上下文。

于是可以做到:一批就绪节点同时干、干完各自回报、下一批自动放行。

二、怎么用

工作区参数 --ws 指向要被推进的工作区根目录。图落在 <ws>/.workbuddy/graph/<gid>/。

1、建图

按判据建(每条判据一个节点,默认无依赖、无域):

python graph/graphctl.py init --ws "<ws>" --title "目标名" --kpi "判据A;判据B;判据C"

按节点定义建(要并行、要依赖、要域,就用这个):

python graph/graphctl.py init --ws "<ws>" --title "目标名" --nodes-json - <<'JSON'
{
  "max_parallel": 3,
  "nodes": [
    {"id": "A", "title": "改界面", "domain": ["ui/"],      "accepts": ["页面能打开"], "deps": []},
    {"id": "B", "title": "改文档", "domain": ["docs/"],    "accepts": ["索引已登记"], "deps": []},
    {"id": "C", "title": "改服务", "domain": ["server/"],  "accepts": ["本地能起"],   "deps": ["A"]},
    {"id": "D", "title": "汇总",   "domain": ["docs/index/"], "accepts": ["三处齐"],  "deps": ["A","B","C"]}
  ]
}
JSON

2、校验

python graph/graphctl.py check --ws "<ws>" --gid "<gid>"

报三件硬错(环、依赖指向不存在的节点、状态非法)和三类警告(没有域、没有判据、两个节点域重叠)。

3、算这一批派谁

python graph/graphctl.py ready --ws "<ws>" --gid "<gid>"

输出 ready(这一批可以同时开工的节点)、backlog(就绪但被并发上限挡住)、waiting(还差什么)。

4、开一个节点、回报

python graph/graphctl.py start  --ws "<ws>" --gid "<gid>" --node A --by "执行会话-xxx"
python graph/graphctl.py report --ws "<ws>" --gid "<gid>" --node A --state done \
    --evidence "页面 1440 宽截图在 ui/shot.png" --artifact ui/shot.png

start 会给节点记租约(见 §三之二);report 可带多个 --artifact(产物清单,供展开节点读取,见 §三之四)。

回报 done 后会立刻返回下一批的 ready,这就是图在往前走的信号。回报 blocked 时若节点配了重试策略,会自动排下一次(见 §三之三)。

5、看状态

python graph/graphctl.py list  --ws "<ws>"
python graph/graphctl.py show  --ws "<ws>" --gid "<gid>"

6、手动展开

python graph/graphctl.py expand --ws "<ws>" --gid "<gid>" --node P [--force]

一般不用手动调:ready 命中条件会自动展开。手动 expand 用于提前铺开、或排查"为什么没展开"。

三、就绪的四个条件

  1. 依赖按 deps_rule 满足(默认 all_success)。
  2. 节点自己是 pending,且不在重试等待窗内(not_before 未到就先等)。
  3. 域不与任何 running 节点重叠(docs/ 与 docs/ui/ 算重叠)。
  4. 加上在跑的节点后,不超过 max_parallel(默认 3)。

三之二、节点租约与超时

解决的问题:一个节点开工后卡死、没人回报,会一直占着并发名额,这一批就永远推不动。

start 时记租约:start 会给节点写 lease_at(ISO)、lease_at_ts(epoch)、starts(开工次数)。租约期限由 --lease-sec(全局)或节点自己的 lease_sec 决定,节点级优先。

ready 时收超时:ready 会先把超过租约的 running 节点按策略处置,再算就绪集。所以在拿"这一批派谁"的同时就把僵死节点清掉了,不需要额外的守护进程。

字段写法(建图时给全局):

python graph/graphctl.py init --ws "<ws>" --title "…" \
    --lease-sec 3600 --on-timeout requeue --nodes-json - <<'JSON'
{"nodes": [
  {"id": "A", "accepts": ["…"], "lease_sec": 120},
  {"id": "B", "accepts": ["…"], "on_timeout": "block"}
]}
JSON
字段 默认值 含义
meta.lease.timeout_sec / --lease-sec 3600 多少秒没回报算超时;0 = 不判超时
meta.lease.on_timeout / --on-timeout requeue 超时怎么处置
节点 lease_sec 跟随全局 这个节点单独的租约秒数
节点 on_timeout 跟随全局 这个节点单独的处置策略

三种策略:

  1. requeue(默认)—— 回 pending,attempts+1,清掉租约,名额立刻还回来。
  2. block —— 进 blocked,blocked_by=lease_timeout,attempts+1,等人工处理。
  3. keep —— 只提示不动;ready 的输出里会多一条 lease_notices。

注意两点:没有 lease_at 的 running 节点(手改出来的旧图)不判超时,保持兼容;回收动作会写一条 lease_timeout 事件,attempts 会涨,所以"自动回收"和"真失败"在计数上是合并的——看 blocked_by 与事件区分。

三之三、失败策略与依赖触发规则

1、节点级重试

report --state blocked 之后怎么办,由节点自己的 retry 决定:

{"id": "A", "accepts": ["…"],
 "retry": {"max": 2, "delay_sec": 30, "backoff": "exponential", "factor": 2}}
字段 默认值 含义
retry.max 0 最多自动重试几次;0 = 不自动重试(blocked 立刻停住)
retry.delay_sec 0 基础间隔秒
retry.backoff fixed fixed 固定/linear 线性递增/exponential 指数
retry.factor 2 指数退避的倍数

语义:attempts 是失败次数。attempts <= max 时回 pending 并写 not_before(下次可开工时刻,retry_at 是同值的可读形式);超过就进 blocked 并标 retry_exhausted=true,不再自动重试。间隔算法:fixed = delay_sec,linear = delay_sec × attempts,exponential = delay_sec × factor^(attempts-1)。

等待窗内 ready 不会派它,waiting 里会写「重试等待中:还需 N 秒」。

全局默认也能在 init 上给:--retry-max / --retry-delay / --retry-backoff / --retry-factor;节点字段覆盖全局。

2、依赖触发规则(deps_rule)

值 放行条件
all_success(默认) 上游全部 done
all_done 上游全部跑完(done 或 blocked 都算),不看成败
one_success 上游任一 done 即可,不等其余的

写法:节点上写 "deps_rule": "all_done",或建图时统一给 --deps-rule all_done。非法值两处都会拦:init 直接报错,手改 JSON 的由 check 报硬错。

三之四、动态展开(map / foreach)

一个节点声明成"按 N 项展开",ready 命中时生成 P#1 … P#N 子节点,父节点转 gating;子节点全部 done 后父节点自动转 done,evidence 里写「展开 N 项全部完成」,子节点的 artifacts 会汇总到父节点上(方便再上一层继续展开)。

1、建图时给列表

{"id": "P", "title": "逐页改文案", "accepts": ["每页都改完"],
 "map": {"over": ["a.md", "b.md", "c.md"], "child_domain": "out/{i}/"}}

2、运行时从上游产物 / 证据读

{"id": "U", "title": "扫描", "accepts": ["清单在"]},
{"id": "P", "title": "逐个处理", "accepts": ["都处理完"], "deps": ["U"],
 "map": {"from": {"node": "U", "pick": "artifacts"}}}
字段 默认值 含义
map.over — 建图时直接给的列表(元素可以是字符串或对象)
map.from.node — 运行时读哪个上游节点
map.from.pick artifacts 读什么:artifacts(产物清单)/evidence(按行或分号切,JSON 数组也认)/children(上游展开出来的子节点)
map.child_domain 空(不继承) 子节点域的模板,{i} 序号、{item} 该项,可给字符串或列表
map.max_items 不限制 最多展开几项(防爆炸)

产物清单要在回报时给:report … --state done --artifact a.md --artifact b.md。

三个必须知道的取舍:

  1. 子节点默认不继承父域。同一批展开通常写同一批文件,继承父域会让域比较把它们互相挡死,N 项只能一个个跑;要分片写盘就写 child_domain 模板(out/{i}/),域不重叠就能并行。
  2. N=0 立即完成:父节点直接 done、不生成子节点,下游可以马上走;空清单不算失败。
  3. N 很大由 max_parallel 兜住:10 项、cap=2 时 ready 只放 2 个,其余进 backlog,跑完一个补一个。

父节点不能 start、也不能 report(它是容器);要提前手动展开用 expand 命令:

python graph/graphctl.py expand --ws "<ws>" --gid "<gid>" --node P [--force]

四、与现有机制的关系

事项 现有机制 图模式
目标定义 goal.json 图文件里的 title / nodes
任务台账 tasks.json 图里每个节点的 status + events.jsonl
派活决策 检查会话读摘要判断 ready 按图算
触发 常驻 + 六道闸 + 静默 2 分钟 调用方驱动(人或会话),无后台进程
会话创建 自动化排期 由调用方决定(图不管怎么开会话)
域锁 开工时声明 同左,节点的 domain 直接喂给开工命令

两套可以同时存在:图模式不读也不改现有那三份文件,现有机制也永远不会看到 graph/ 目录。

五、怎么跑测试

python graph/test_graphctl.py -v

全绿应看到 Ran 92 tests ... OK,用时 70 秒上下(每条用例起若干次子进程;52 条是原有基线,40 条是 2026-10-19 为三件批量能力补的)。

测试只往临时目录写:<GRAPH_TEST_TMP>/graph-test-<pid>/<用例名>,默认 GRAPH_TEST_TMP = AIProject/ai1net-dsh-server/tmp。每条用例只新建自己的目录、跑完不删,整轮收尾由 tearDownModule 清一次;这样一次跑下来新建几十个文件、不触发本机沙箱的「批量删除」守卫(那个守卫一次删满 50 个文件就会拦,曾让 20 条用例报 ERROR,而用例本身零失败)。想换落点就设环境变量 GRAPH_TEST_TMP。

五之二、并发写是怎么保证的

两个进程可以同时对同一张图动手(比如两个节点同时开工),做法是四层:

  1. 写串行化:每次读-改-写先进 <gid>/.lock(os.mkdir 原子建目录,⛔ 不用 OS 文件锁,本机会卡死),写完释放;锁目录超过 30 秒没动静认定持锁进程已死、直接接管,等 10 秒仍拿不到则报错退出(带手工清理提示)。
  2. 临时文件名带进程号与随机数,两个进程写字不会撞同一个名字(早期版本用固定名 graph.json.tmp,并发时直接报 PermissionError)。
  3. 每次写都在最新磁盘状态上重放本次变更,再原子替换——避免「后写的整份覆盖前写的」导致节点状态静默消失。
  4. 图里带 _rev 与 _token:写完回读核对,对不上就重读重做,连续三次仍被覆盖才报错退出(「宁可报错,也不静默丢状态」)。

为什么多加第 1 层:2026-10-19 实测只靠第 4 层仍会丢更新 —— A 写完回读确认后、B 再整份覆盖,B 回读看到的也是自己的 token,两边都"成功",但 A 的节点状态没了(表现为并发用例 pending != running,四轮抖一轮)。加上目录锁后连跑三轮 92 条全绿。

这条路径由 T9_Concurrency 的三个用例守着:并发开工两个节点、并发回报两个节点、版本号每次写都前进。

清残留在本机是常态:跑完后 tmp/ 下偶尔还能看到 graph-test-<pid>/(进程内已确认删空,是本机沙箱在进程退出后把删除路径又放回来了,与测试代码无关)。一条命令清掉:

rm -rf "E:/ProgramData/AIProject/ai1net-dsh-server/tmp"/graph-test-*

十二个用例类各管一摊:

1、T1_Init —— 建图:--kpi 按条数生成节点(全角分号也认)、--nodes-json 带依赖与域、重复 id 报错、自动编号避让、max_parallel。 2、T2_CheckErrors —— 硬错三条:环(含自环)、依赖指向不存在的节点、状态非法。 3、T3_CheckWarnings —— 警告三类:无域、无判据、域重叠(docs/ 与 docs/ui/ 双向都算)。 4、T4_Ready —— 就绪集:依赖未满足、域与在跑冲突、超 max_parallel(running 占名额)、两个域重叠的节点不进同一批。 5、T5_Flow —— 流转:pending→running→done、events.jsonl 只追加、blocked 累加 attempts、回报后回带 next_ready。 6、T6_Illegal —— 非法操作:running/done 再 start 拒绝,blocked 可重开;--state 只认 done / blocked;不存在的节点/图报错。 7、T7_Edge —— 边界:单节点图、空节点图、gid 含中文(显式与自动生成各一条)、list 统计与空工作区。 8、T8_Isolation —— 独立性:工作区里预埋的 goal.json / tasks.json / collabd-state.json 跑完字节不变,且不新建这类文件,写入范围只有 <ws>/.workbuddy/graph/。 9、T9_Concurrency —— 并发写:两进程同时开工/同时回报都不许丢状态,_rev 每次写都前进。 10、T10_Lease —— 租约:start 记租约、超时回收(回 pending / block / keep 三种策略)、回收后名额还给别人、节点级 lease_sec 覆盖全局、--lease-sec 0 关闭、手改的无租约 running 不误杀、回收写事件。 11、T11_Retry —— 重试与触发:重排写 not_before、等待窗内不派、fixed/linear/exponential 三种间隔、超限进 blocked 且 retry_exhausted、默认不自动重试、all_success/all_done/one_success 三种触发、非法 deps_rule 双处拦截。 12、T12_Map —— 展开:over 列表、N=0 立即完成、从 evidence/artifacts 读 N 项、子节点全完成收敛父节点、N=10 但 cap=2 只放 2 个、child_domain 模板、默认不继承域、父节点不许 start/report、expand 命令与 --force、子节点 attempts 独立。

测试对 graphctl.py 的唯一要求是「能当命令行跑」,不 import 它的内部函数 —— 所以改内部实现不会连带改测试。

六、还没做的(下一步)

  • 自动派活:读 ready 后自动建排期(要复用现有排期硬规则:cwds 正斜杠、workspace_scope、next_run_at 毫秒、scheduled_at ISO)。
  • 判定节点完成:现在靠回报 --state done,还没有「看证据够不够」的自动判定。
  • 回退边:attempts 与重试已在记,但还没有「判不过回到某个上游节点」的字段(现在的回退只有"自己重试")。
  • 展开源变化:map.from 只在上游 done 之后读一次,上游重跑(回退)不会重新展开。

这几件都只在本目录内加,不影响现有机制。