Files
workbuddy_skills/session-mechanism/graph/README.md
T
admin ce05207aa2 graph: 补齐图模式剩余四件(验收机判/回退边/运行期改图/跨图子图),163 条测试全绿
- F1 accepts 机判:report done 前按 artifact:/evidence: 逐条校验,缺什么报什么,不许绕过
- F2 rework_to 回退边:判不过回到指定上游重做,attempts/reworks/事件齐备,人审驳回同路
- F3 运行期改图:add-node / remove-edge,目录锁+唯一临时名+原子替换+_rev 回读,并发不丢更新
- F4 跨图子图:subgraph.gid 引用同工作区另一张图,deps/when/rework_to 加前缀,父等收敛;缺图/自引用/跨图环/嵌套均硬报错
- 测试:122 → 163,新增 T20-T23;顺带修测试进程被本机批量删除守卫误杀的假红
2026-10-19 23:57:40 +08:00

431 lines
28 KiB
Markdown
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.
# 图模式(独立功能)
> 2026-10-18 新增;2026-10-19 补「租约超时 / 重试与触发规则 / 动态展开」三件批量能力,同日再补「验收机判 / 回退边 / 运行期改图 / 跨图子图」四件。
> **这一套与现有「项目机制」完全并列**:不读写 `goal.json`、`tasks.json`、`collabd-state.json`,不参与六道闸,不接管常驻循环,不改变现有任何会话的行为。
> 停用方式:不调用它就行;它没有常驻进程、没有注册任何钩子、没有改任何现有文件。
>
> 命令一览:`init` `check` `ready` `start` `report` **`rework`** **`add-node`** **`remove-edge`** `expand` `approve` `cancel` `tick` `plan` `progress` `show` `list`(加粗为 2026-10-19 新增)。
## 一、它解决什么
现有机制是「一棒干一堆、由会话读摘要决定下一棒」。图模式把这层换成:
- **节点**:一块独立的活,带自己的**域**(允许写哪些目录)和**判据**。
- **边**:节点之间的依赖;依赖全 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]
## 三之五、条件分支(`when` + `on_dep_fail`)
节点加 `when` 子句就变成有条件的节点:条件全成立才放行,任一被证伪直接 `skipped`。
{"id": "B", "deps": ["A"], "deps_rule": "all_done",
"when": [{"node": "A", "status": "done"}]}
{"id": "C", "deps": ["A"], "deps_rule": "all_done",
"when": [{"node": "A", "status": "blocked"}]}
`A` 成了走 `B`、`A` 败了走 `C`,就是这样写出来的。判「成 / 败」用 `deps_rule: all_done`(`blocked` 也算有定论),用默认的 `all_success` 会先卡在依赖上。
上游跑不成时下游怎么办,由 `on_dep_fail` 决定,三档:
1. `wait`(默认)—— 停在待办里等,与旧行为一致。
2. `skip` —— 连坐跳过,一路往下跳。
3. `block` —— 整枝停住,进 `blocked`(`blocked_by: dep`)。
`skipped` 是终态:不会再被派活、不算进进度分母、下游按 `on_dep_fail` 继续传。
## 三之六、子图
节点内部再拆流程,用 `subgraph` 内联写,展开成 `<父id>/<子id>`:
{"id": "P", "domain": ["docs/"], "subgraph": {"nodes": [
{"id": "s1", "accepts": ["文档写完了"]},
{"id": "s2", "deps": ["s1"], "accepts": ["索引登记了"]}]}}
规则四条:
1. 子节点 id 自动加 `<父>/` 前缀,内部 `deps` 也跟着映射过去,所以子图里怎么写 `deps` 都行。
2. 子节点**默认继承父节点域**(子流程通常写同一块);要分片就用 `subgraph.child_domain`,支持 `{id}`。
3. 父节点展开后进 `gating`,子节点全 `done` ⇒ 父 `done`;子节点全定下来但有不满意的 ⇒ 父 `blocked`(`blocked_by: child_failed`)。
4. `map` 与 `subgraph` 只能选一个,同时写直接报错。
`subgraph` 里还能再套 `subgraph`,前缀会一层层叠上去。
### 跨图子图:引用同工作区的另一张图
除内联外,`subgraph` 还可以写 `{"gid": "<另一张图的 gid>"}`,把那张图的节点定义搬进来当本图的子流程:
# 先建被引用的那张(同工作区、任何 gid)
python graph/graphctl.py init --ws "<ws>" --title "验证流程" --gid gverify --nodes-json -
{"nodes": [{"id": "S1", "domain": ["docs/"], "accepts": ["artifact:docs/a.md"]},
{"id": "S2", "domain": ["docs/"], "accepts": ["artifact:docs/b.md"],
"deps": ["S1"]}]}
# 再建引用它的那张
python graph/graphctl.py init --ws "<ws>" --title "主流程" --gid gmain --nodes-json -
{"nodes": [{"id": "P", "subgraph": {"gid": "gverify"}},
{"id": "Q", "deps": ["P"], "accepts": ["汇总写完"]}]}
python graph/graphctl.py check --ws "<ws>" --gid gmain # 引用在不在、成没成环,这里就报
展开后子节点 id 是 `P/S1`、`P/S2`,父节点 `P` 进 `gating`,**等它的子节点全部收敛**才算完(全 `done` ⇒ `done`;有不满意的 ⇒ `blocked`),下游 `Q` 在 `P` 收敛前不派。
规则五条:
1. **只搬定义、不搬状态** —— `title` / `deps` / `domain` / `accepts` / `deps_rule` / `on_dep_fail` / `when` / `retry` / `lease_sec` / `approval` / `rework_to` 都搬过去,被引用图里"跑到哪一步"不带过来(引用即重跑一遍)。子节点上会记 `origin_graph`,看板能看出它是从哪张图来的。
2. 子图内部的 `deps`、`when.node`、`rework_to` 都加父前缀(`S1` ⇒ `P/S1`),写法与内联子图一致。
3. **不嵌套**:被引用的图里若有 `map` 或 `subgraph`,`check` 直接报硬错(引用关系已经是一层,再叠会失控)。
4. **不许成环**:引用自己、`A→B→A`、超过 6 层引用链,`check` 与展开时都报错。
5. `subgraph` 里 `nodes` 与 `gid` **只能选一种**,同时写报错。
⚠️ 被引用的图**改了定义不会回溯**已展开的子节点 —— 要按新定义跑,用 `rework` 把父节点退回去重展开(见 §三之十一)。
## 三之七、人审节点与取消
人审:节点写 `"approval": true`,回报 `done` 后不进 `done`,而是进 `awaiting` 等人过目:
python graph/graphctl.py approve --ws "<ws>" --gid "<gid>" --node A --by user
python graph/graphctl.py approve --ws "<ws>" --gid "<gid>" --node A --reject --reason "不合格"
通过 ⇒ `done`(记 `approved_at` / `approved_by`);驳回 ⇒ `blocked`(`blocked_by: rejected`)。下游在 `awaiting` 期间一律不派。
取消:`cancelled` 是终态,会连坐子节点:
python graph/graphctl.py cancel --ws "<ws>" --gid "<gid>" --node P --reason "改需求"
python graph/graphctl.py cancel --ws "<ws>" --gid "<gid>" --all
已经 `done` 的节点不被取消(结果不能撤回)。下游按 `on_dep_fail` 处置,配 `skip` 就整枝跳过。
## 三之八、自动派活与排期
`tick` 是「算完就开」:一次调用把超时回收、展开、条件判定推到不动点,再按就绪集直接把节点开成 `running`。
python graph/graphctl.py tick --ws "<ws>" --gid "<gid>" --by tick [--limit N] [--dry-run]
输出 `started`(这次开了谁)、`missed`(算的时候还行、写的时候被别人改了,通常为空)、`ready_now`(下一批)、`slots`(还剩几个名额)。
`plan` 给上层排期用,出的一行已经把四条硬规则写成规范形态:
python graph/graphctl.py plan --ws "<ws>" --gid "<gid>" [--scope "<线名>/"] [--after-sec 240]
1. `cwds` —— 正斜杠绝对路径。
2. `workspace_scope` —— 线名加尾斜杠,默认取工作区目录名。
3. `next_run_at` —— 毫秒 epoch。
4. `scheduled_at` —— ISO 本地时间。
`plan` 只产出行,**不写宿主库** —— 图模式零侵入这条不变,落库由上层一条命令完成。
## 三之九、按域分池与进度
分池:同域并发单独限流,与 `max_parallel` 叠加生效。
python graph/graphctl.py init ... --pool "docs/ui/=1" --pool "server/=2"
{"pools": {"docs/ui/": 1}} # 写进 nodes-json 也可以
判定用的是域前缀重叠:节点域 `docs/ui/a/` 落进 `docs/ui/` 这个池。
进度与关键路径:
python graph/graphctl.py progress --ws "<ws>" --gid "<gid>"
输出 `total` / `by_status` / `percent`(分母排除 `skipped` 与 `cancelled`)、`blocked` / `awaiting`、`critical_path`(**未完成节点之间的最长依赖链**,告诉你哪条链决定工期)。
## 三之十、验收判据机判(`accepts`)
节点的 `accepts` 不只是给人看的清单 —— 报 `done` 时**先按它校验产物与证据**,过不了就不许置 `done`,并逐条说明缺什么。
两种写法:
"artifact:docs/*.md" 产物里至少一条匹配这个通配
"artifact:>=2" 产物至少 2 条
"evidence:测试通过" 证据里必须出现「测试通过」
"evidence:全绿,零回归" 多个关键词(`,` 或 `,` 分隔)⇒ 全部都要出现
"evidence:>=20" 证据至少 20 个字符
{"kind": "artifact", "glob": "docs/*.md"} 等价写法
{"kind": "artifact", "min": 2}
{"kind": "evidence", "contains": ["全绿", "零回归"]}
{"kind": "evidence", "min": 20}
**纯文本条目(如 `"产物在位"`)=描述性判据,不参与机判** —— 老图照旧能用,不会被新闸误杀。
校验时读的是**本次 + 历史**的合并视图(`--artifact` 是追加语义),路径比较时 `\` 与 `/` 等价。不通过长这样:
python graph/graphctl.py report --ws "<ws>" --gid "<gid>" --node A --state done --evidence "全绿"
节点 A 验收不通过(不置 done):
- 缺产物 artifact:docs/a.md(现有产物:无)
- 证据缺关键词 evidence:全绿,零回归(现缺:零回归)
补齐产物/证据后重新 report(accepts 不可绕过)
`check` 也会校验语法:`"artifact:"` 这种空参数、`{"kind": "nope"}` 这种非法对象,建图时就被拒。
## 三之十一、回退边(`rework_to`)
节点写 `"rework_to": "A"`(也可写列表,取第一个)。判不过时**回到指定的上游节点重做**,而不是只能自己重试:
python graph/graphctl.py rework --ws "<ws>" --gid "<gid>" --node Q --reason "输出不合格"
python graph/graphctl.py rework --ws "<ws>" --gid "<gid>" --node Q --to A # 临时指定目标
做的事:
1. **目标必须是上游**(`Q` 的传递依赖),⛔ 不能是自己、不能是下游 —— `check` 也会提前报。
2. 目标及其**全部传递下游**复位 `pending`:清租约、`done_at`、`awaiting`、驳回原因、重试等待窗。
3. 复位时把 `evidence` / `artifacts` 挪进 `prev_evidence` / `prev_artifacts` 再清空 —— 重做之后必须**重新交产物**,免得旧产物把验收闸蒙过去。
4. 目标 `attempts +1`、全链 `reworks +1`,`rework_from` 记是谁判不过的,并写一条 `event=rework` 的流水(含 `reset` 名单与原因)。
5. 链上有已展开(`map`/子图)的节点 ⇒ **收回它的子节点、清掉 `expanded`**,下次 `ready`/`tick` 按当时的输入重新展开,不留半截子节点。收回的 id 在输出的 `dropped_children` 里。
6. 链上有 `running` 节点 ⇒ 报错停手(先 `cancel` 或等它结束),⛔ 不硬抢。
人审那边也接上了:`approve --reject` 时若节点带 `rework_to`,**自动走同一套回退**(人审判不过就是判不过),输出里 `rework.auto = true`。
## 三之十二、运行期改图(加节点、删边)
图跑起来以后还能改结构,两条命令:
python graph/graphctl.py add-node --ws "<ws>" --gid "<gid>" --node N9 \
--title "补一份索引" --deps A,B --domain docs/index/ --accepts "artifact:docs/index.md"
python graph/graphctl.py remove-edge --ws "<ws>" --gid "<gid>" --from A --to B
- `add-node`:新节点进 `pending`,`deps` 必须都是已存在的节点;`--accepts` 可重复、写法同 §三之十;还能带 `--deps-rule` / `--rework-to` / `--approval`。id 重复、依赖悬空都直接报错。
- `remove-edge`:删掉「`B` 依赖 `A`」这一条。边不存在就报错(⛔ 不静默空操作),删完立刻按新依赖重算就绪集。
- **并发安全**:两条命令与 `start` / `report` 共用同一把目录锁(`_Lock` + 唯一临时名 + `_rev` 回读核对),六个进程同时加节点,六个节点全在、`_rev` 前进 6。实测见 `T22_Mutate`。
## 四、与现有机制的关系
| 事项 | 现有机制 | 图模式 |
|---|---|---|
| 目标定义 | `goal.json` | 图文件里的 `title` / `nodes` |
| 任务台账 | `tasks.json` | 图里每个节点的 `status` + `events.jsonl` |
| 派活决策 | 检查会话读摘要判断 | `ready` 按图算 |
| 触发 | 常驻 + 六道闸 + 静默 2 分钟 | 调用方驱动(人或会话),无后台进程 |
| 会话创建 | 自动化排期 | 由调用方决定(图不管怎么开会话) |
| 域锁 | 开工时声明 | 同左,节点的 `domain` 直接喂给开工命令 |
**两套可以同时存在**:图模式不读也不改现有那三份文件,现有机制也永远不会看到 `graph/` 目录。
## 五、怎么跑测试
python graph/test_graphctl.py -v
全绿应看到 `Ran 163 tests ... OK`,用时 300 秒上下(每条用例起若干次子进程;52 条是初版基线,40 条是 2026-10-19 为三件批量能力补的,30 条是同日的条件分支/子图/人审/取消/分池/派活/进度,41 条是同日为「验收机判/回退边/运行期改图/跨图子图」四件补的)。
测试只往临时目录写:`<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 独立。
13、`T13_Condition` —— 条件分支:A 成走 B、A 败走 C、跳过向下游级联、非法 `on_dep_fail` 双处拦截。
14、`T14_Subgraph` —— 子图:展开成 `<父>/<子id>`、内部 deps 加前缀、子节点全成父 done、有子节点失败父 blocked、空子图直接完成、与 `map` 互斥。
15、`T15_Approval` —— 人审:`done` 后停在 `awaiting`、下游不放行、通过后放行、驳回进 `blocked`、非 `awaiting` 拒批。
16、`T16_Cancel` —— 取消:连坐子节点、`--all`、不动已 `done`、下游按 `on_dep_fail` 跳过、缺目标报错。
17、`T17_Pools` —— 分池:同域只放 1 个、`--pool` 落进 `meta.pools`、上限 0 被拒。
18、`T18_Dispatch` —— 派活:`tick` 按并发上限开、`--dry-run` 不动状态、等依赖、`--limit`;`plan` 四条硬规则形态。
19、`T19_Progress` —— 进度:`percent` 与 `by_status`、关键路径跳过已完成、全完成 100%。
20、`T20_Accepts` —— 验收机判:缺产物/缺关键词/证据太短/产物数量不足都拒收且报缺什么;反斜杠等价;纯文本条目不拦;`blocked` 不受影响;人审节点同样先过验收;`check` 与 `init` 双处拦非法语法。
21、`T21_Rework` —— 回退边:复位目标与全部下游、`attempts`/`reworks`/`rework_from` 落位、写 `rework` 事件、旧产物挪 `prev_*`、目标必须是上游、不能是自己、没配 `rework_to` 报错、人审驳回自动回退、已展开节点收回子节点后重展开。
22、`T22_Mutate` —— 运行期改图:`add-node` 字段与事件、重复 id/悬空依赖报错、`remove-edge` 删完即可派、边不存在报错、四进程并发加节点不丢更新(`_rev` = 4)、运行中加节点可被 `ready` 选中。
23、`T23_SubgraphRef` —— 跨图子图:从另一张图展开、内部依赖加前缀、父等全部子节点收敛、子节点失败父 `blocked`、缺图/自引用/跨图环/`nodes` 与 `gid` 互斥/被引用图里嵌 `map` 全报错、定义搬过来但按新图重跑。
测试对 `graphctl.py` 的唯一要求是「能当命令行跑」,不 import 它的内部函数 —— 所以改内部实现不会连带改测试。
## 六、还没做的(下一步)
- 展开源变化:`map.from` 只在上游 `done` 之后读一次,上游重跑(回退)不会自动重新展开(`rework` 会把已展开节点收回去重建,但那是显式动作)。
- 排期落库:`plan` 只出规范行,写进宿主排期表由上层完成。
- 删节点:现在只有 `add-node` / `remove-edge`;删节点要先 `cancel`,再从图里摘结构这一步没做。
- 跨图引用不带版本:引用的是"那张图当前的定义",被引用图改了不会回溯已展开的子节点(要按新定义跑得 `rework`)。
这几件都只在本目录内加,不影响现有机制。