2026-10-18 22:28:28 +08:00
|
|
|
|
# 图模式(独立功能)
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
> 2026-10-18 新增;2026-10-19 补「租约超时 / 重试与触发规则 / 动态展开」三件批量能力。
|
|
|
|
|
|
> **这一套与现有「项目机制」完全并列**:不读写 `goal.json`、`tasks.json`、`collabd-state.json`,不参与六道闸,不接管常驻循环,不改变现有任何会话的行为。
|
2026-10-18 22:28:28 +08:00
|
|
|
|
> 停用方式:不调用它就行;它没有常驻进程、没有注册任何钩子、没有改任何现有文件。
|
|
|
|
|
|
|
|
|
|
|
|
## 一、它解决什么
|
|
|
|
|
|
|
|
|
|
|
|
现有机制是「一棒干一堆、由会话读摘要决定下一棒」。图模式把这层换成:
|
|
|
|
|
|
|
|
|
|
|
|
- **节点**:一块独立的活,带自己的**域**(允许写哪些目录)和**判据**。
|
|
|
|
|
|
- **边**:节点之间的依赖;依赖全 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"
|
2026-10-19 21:11:27 +08:00
|
|
|
|
python graph/graphctl.py report --ws "<ws>" --gid "<gid>" --node A --state done \
|
|
|
|
|
|
--evidence "页面 1440 宽截图在 ui/shot.png" --artifact ui/shot.png
|
2026-10-18 22:28:28 +08:00
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
`start` 会给节点记租约(见 §三之二);`report` 可带多个 `--artifact`(产物清单,供展开节点读取,见 §三之四)。
|
|
|
|
|
|
|
|
|
|
|
|
回报 `done` 后会立刻返回下一批的 `ready`,这就是图在往前走的信号。回报 `blocked` 时若节点配了重试策略,会自动排下一次(见 §三之三)。
|
2026-10-18 22:28:28 +08:00
|
|
|
|
|
|
|
|
|
|
### 5、看状态
|
|
|
|
|
|
|
|
|
|
|
|
python graph/graphctl.py list --ws "<ws>"
|
|
|
|
|
|
python graph/graphctl.py show --ws "<ws>" --gid "<gid>"
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
### 6、手动展开
|
|
|
|
|
|
|
|
|
|
|
|
python graph/graphctl.py expand --ws "<ws>" --gid "<gid>" --node P [--force]
|
|
|
|
|
|
|
|
|
|
|
|
一般不用手动调:`ready` 命中条件会自动展开。手动 `expand` 用于提前铺开、或排查"为什么没展开"。
|
|
|
|
|
|
|
2026-10-18 22:28:28 +08:00
|
|
|
|
## 三、就绪的四个条件
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
1. 依赖按 `deps_rule` 满足(默认 `all_success`)。
|
|
|
|
|
|
2. 节点自己是 `pending`,且不在重试等待窗内(`not_before` 未到就先等)。
|
2026-10-18 22:28:28 +08:00
|
|
|
|
3. 域不与任何 `running` 节点重叠(`docs/` 与 `docs/ui/` 算重叠)。
|
|
|
|
|
|
4. 加上在跑的节点后,不超过 `max_parallel`(默认 3)。
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
## 三之二、节点租约与超时
|
|
|
|
|
|
|
|
|
|
|
|
解决的问题:一个节点开工后卡死、没人回报,会一直占着并发名额,这一批就永远推不动。
|
|
|
|
|
|
|
|
|
|
|
|
**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]
|
|
|
|
|
|
|
2026-10-18 22:28:28 +08:00
|
|
|
|
## 四、与现有机制的关系
|
|
|
|
|
|
|
|
|
|
|
|
| 事项 | 现有机制 | 图模式 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 目标定义 | `goal.json` | 图文件里的 `title` / `nodes` |
|
|
|
|
|
|
| 任务台账 | `tasks.json` | 图里每个节点的 `status` + `events.jsonl` |
|
|
|
|
|
|
| 派活决策 | 检查会话读摘要判断 | `ready` 按图算 |
|
|
|
|
|
|
| 触发 | 常驻 + 六道闸 + 静默 2 分钟 | 调用方驱动(人或会话),无后台进程 |
|
|
|
|
|
|
| 会话创建 | 自动化排期 | 由调用方决定(图不管怎么开会话) |
|
|
|
|
|
|
| 域锁 | 开工时声明 | 同左,节点的 `domain` 直接喂给开工命令 |
|
|
|
|
|
|
|
|
|
|
|
|
**两套可以同时存在**:图模式不读也不改现有那三份文件,现有机制也永远不会看到 `graph/` 目录。
|
|
|
|
|
|
|
2026-10-19 08:18:57 +08:00
|
|
|
|
## 五、怎么跑测试
|
|
|
|
|
|
|
|
|
|
|
|
python graph/test_graphctl.py -v
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
全绿应看到 `Ran 92 tests ... OK`,用时 70 秒上下(每条用例起若干次子进程;52 条是原有基线,40 条是 2026-10-19 为三件批量能力补的)。
|
2026-10-19 08:18:57 +08:00
|
|
|
|
|
2026-10-19 20:09:24 +08:00
|
|
|
|
测试只往临时目录写:`<GRAPH_TEST_TMP>/graph-test-<pid>/<用例名>`,默认 `GRAPH_TEST_TMP` = `AIProject/ai1net-dsh-server/tmp`。每条用例只**新建**自己的目录、跑完不删,整轮收尾由 `tearDownModule` 清一次;这样一次跑下来新建几十个文件、不触发本机沙箱的「批量删除」守卫(那个守卫一次删满 50 个文件就会拦,曾让 20 条用例报 ERROR,而用例本身零失败)。想换落点就设环境变量 `GRAPH_TEST_TMP`。
|
|
|
|
|
|
|
|
|
|
|
|
## 五之二、并发写是怎么保证的
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
两个进程可以同时对同一张图动手(比如两个节点同时开工),做法是四层:
|
2026-10-19 20:09:24 +08:00
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
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 条全绿。
|
2026-10-19 20:09:24 +08:00
|
|
|
|
|
|
|
|
|
|
这条路径由 `T9_Concurrency` 的三个用例守着:并发开工两个节点、并发回报两个节点、版本号每次写都前进。
|
2026-10-19 08:18:57 +08:00
|
|
|
|
|
|
|
|
|
|
清残留在本机是常态:跑完后 `tmp/` 下偶尔还能看到 `graph-test-<pid>/`(进程内已确认删空,是本机沙箱在进程退出后把删除路径又放回来了,与测试代码无关)。一条命令清掉:
|
|
|
|
|
|
|
|
|
|
|
|
rm -rf "E:/ProgramData/AIProject/ai1net-dsh-server/tmp"/graph-test-*
|
|
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
十二个用例类各管一摊:
|
2026-10-19 08:18:57 +08:00
|
|
|
|
|
|
|
|
|
|
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/`。
|
2026-10-19 21:11:27 +08:00
|
|
|
|
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 独立。
|
2026-10-19 08:18:57 +08:00
|
|
|
|
|
|
|
|
|
|
测试对 `graphctl.py` 的唯一要求是「能当命令行跑」,不 import 它的内部函数 —— 所以改内部实现不会连带改测试。
|
|
|
|
|
|
|
|
|
|
|
|
## 六、还没做的(下一步)
|
2026-10-18 22:28:28 +08:00
|
|
|
|
|
|
|
|
|
|
- 自动派活:读 `ready` 后自动建排期(要复用现有排期硬规则:cwds 正斜杠、workspace_scope、next_run_at 毫秒、scheduled_at ISO)。
|
|
|
|
|
|
- 判定节点完成:现在靠回报 `--state done`,还没有「看证据够不够」的自动判定。
|
2026-10-19 21:11:27 +08:00
|
|
|
|
- 回退边:`attempts` 与重试已在记,但还没有「判不过回到某个上游节点」的字段(现在的回退只有"自己重试")。
|
|
|
|
|
|
- 展开源变化:`map.from` 只在上游 `done` 之后读一次,上游重跑(回退)不会重新展开。
|
2026-10-18 22:28:28 +08:00
|
|
|
|
|
2026-10-19 21:11:27 +08:00
|
|
|
|
这几件都只在本目录内加,不影响现有机制。
|