@haiyangbg/buildbeat 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,28 @@
2
2
 
3
3
  > 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
4
4
 
5
+ ## v3.1.0 — 2026-09-26(运行时修复:预算误报、锁残留、台账并发、配置与 YAML 校验)
6
+
7
+ - YAML 子集解析器不再绊倒常见写法:空的 `[]` / `{}` 可用(非空行内集合仍拒绝,报错提示改成每项一行);列表项可与所属键同缩进;不带引号的 `- http://x` 按字符串解析,含 `": "` 且前半截不是合法键的项(如 `- echo a: b`)不猜、报错要求加引号;开头的 BOM 被忽略;`007` 这类前导零保留为字符串;「has no value」报错给出改法。修改前的解析器冻结在 `tests/support/yaml-subset-v1.js`,测试断言仓库内它能解析的每个 YAML 新旧结果完全一致;SKILL.md、快速上手(中英)与 run-config 样板同步。
8
+
9
+ - run-config 在做任何事之前整体校验并一次列出全部问题:必填键、未知顶层键与 worker / envelope 未知字段(给最接近的拼写)、类型与取值(`inheritEnv: yes` 不再静默当 false)、worker 名须被工作流用到、`stopAt` / `entry` 须是工作流步骤、`work` / `run` 的字符与类型(`run: 007` 要求加引号)。缺 `repo` 不再报 Node 内部错误 `paths[1]`。`start` / `resume` / `doctor` / `preflight` / `approve --config` 统一经由它;仓库内全部 run-config 有测试兜底兼容。Workflow 指南与恢复手册(中英)同步。
10
+
11
+ - 修复会话手修交回时丢额度:`resume --adopt <sha>` 回答一次带 `grants` 的预算停车时,候选虽换成新提交,仍继承请求上的 grants(计划未变的前提下),重验后直接进入下一轮 review,不再在同一轮第二次停 `resume-review`。普通批准仍要求 subject 一致,刷新过的请求仍不继承。抢锁测试在断言失败时也会结束子进程、不再挂住测试进程。
12
+
13
+ - 修复并发写台账把台账写坏:批准 / 拒绝 / `--adopt` / `stop` / `resume` / 自动取代改为拿到 Run 锁之后才读台账,并在锁内基于新读到的状态判断与写入(此前先读后锁,两个会话几乎同时操作同一 Run 时,后写者会写出重复 seq、断开哈希链,台账从此判定损坏)。台账写入另加兜底:文件在读取后被别人写过就拒绝写入(`changed on disk since it was read`),不写任何字节,重试即可。确定性交错测试在旧代码上复现「hash chain broken」,修复后通过;另有真实多进程并发测试;恢复手册(中英)同步。
14
+
15
+ - 修复驱动进程被杀后锁残留把 Run 卡死:锁记录持有者(pid、主机、获取时间、命令),`resume` / `stop` / `start` 拿锁时若持有者在本机且进程已不存在即自动回收(锁目录在接管期间始终存在,接管者以「死者世代」命名的独占 claim 竞争、只有一个能赢,赢家再原子替换持有者记录;接管中途死掉的接管者不会把锁卡死;普通拿锁方在此期间一律看到「已被持有」,不会出现两个持有者);持有者存活、在别的主机或没有记录时不回收,报错写明持有者与下一步。`gc` 同样回收持有者已死的 `active-run` 锁。被 SIGKILL 的真实驱动可直接 `resume` 走崩溃恢复(有集成测试);恢复手册(中英)同步。
16
+
17
+ - 修复 `resume --config` 找不到自动编号 Run:精确台账优先,否则选择家族唯一未终态 Run;新增 `--run <RUN-ID>` 显式选择,无候选或多候选时提供诊断。非终态批准提示使用 `buildbeat resume` 并带真实 Run ID,帮助与中英文指南同步;只读步预算提示改为每轮计费。
18
+
19
+ - 修复预算误停车:非只读步成功不扣次数,真失败与只读 review 轮次继续消耗预算;增加总 attempt 的 3 倍兜底,防止成功循环失控。
20
+ - review 到顶且发现阻断问题时提前在 `enter-fix` 一次批准修复、重验、再审;Run/Work review 上限同时放行,过期请求不继承扩额。预算提示显示实际用量、真失败次数及批准/拒绝的含义。
21
+ - 事件仅增加可选 `free` / `grants` 字段,旧台账回放保持兼容;默认预算数值不变。放行计划钉在第一条 `BUDGET_EXTENDED` 上,放行中途进程被杀后 `resume` 按原计划补齐。停车提示为英文、带「已用/上限/真失败次数」与批准、拒绝的含义。
22
+
5
23
  ## v3.0.1 — 2026-09-09(补丁:示例项目、英文指南)
6
24
 
25
+ > **发布状态**:`@haiyangbg/buildbeat@3.0.1` 已于 2026-09-09 从 `main`(PR #41,merge commit `c322ce9`,tag `v3.0.1`)经 OIDC Trusted Publishing 发布到 dist-tag **`latest`**(run 34370800960,双 job 一次 success;所有者授权「发 3.0.1」)。独立回读(直连 npmjs.org):`latest` = 3.0.1、integrity 与本地 dry-run 一致、attestation、隔离安装、包内 `example/` 与四篇英文指南在位全过,GitHub Release v3.0.1 标 Latest,证据见 [`docs/V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`](docs/V3.0.1-RELEASE-EVIDENCE-2026-09-09.md)。
26
+
7
27
  - **英文指南补齐四篇**:快速开始、Human Approval、Evidence、故障恢复各加 `.en.md`(与中文逐节对应,互相加语言切换行);指南索引、docs 总入口、英文 README 指向英文版。快速开始安装注释里的 `BuildBeat v2 runtime` 改为 3.0.0 实际打印的 `BuildBeat runtime`,信封存在性说明去掉版本号。
8
28
  - **示例项目回来了**:`example/` 现在是虚构单仓项目「简账」跑完一个 Work 的快照——填好的 `AGENTS.md` / `指挥台.md` / `BUILDBEAT.md` / `pm/decisions.md`、通知与 observe 配置样例、带项目环境事实的信封、完整的 `delivery/work/WORK-EXPORT-DATE-FILTER/`(intent / plan / run-config / workflow 副本)以及运行时真跑一遍得到的 `decisions.jsonl` 与 `run-record.json`,外加应用本体与真实 `npm test`。随 npm 包与 Claude 插件分发;`tests/example-firstrun.test.js` 锁住工件一致性并把原样拷贝再跑到合并决定。README、docs 索引、SKILL §8.3 指向它。
9
29
 
package/SKILL.md CHANGED
@@ -31,7 +31,7 @@ description: BuildBeat —— 面向人和 AI 会话的工程交付工作流,上
31
31
  | 「开个 Work:〔目标〕」 | 写 `delivery/work/<ID>/intent.md`(为什么做 + **止损线**:最多几个 Run / 几轮 review / 几小时,越线先问人)+ `plan.md`(怎么做)+ `run-config.yaml`(`budgets.reviewRoundsPerWork` 对应止损线);给用户看摘要 | 「看完说接受」;用户说「接受」→ `accept --artifact intent` / `--artifact plan`(digest 绑定) |
32
32
  | 「开工」「再来一轮」 | 先 `buildbeat doctor --config <run-config.yaml>`(会报本仓 intent/plan 是否存在且已接受、哪条 policy 会把 start 挡在哪步、每步预算),再 `start --config <run-config.yaml> --attempt new`(自动编号 RUN-X-01/02…,自动作废同 Work 的旧等待;**用 nohup/setsid 脱离启动**) | 「已起 RUN-X-02,停在合并决定时会通知/我会告诉你」 |
33
33
  | 「怎么样了」「卡住了吗」「正常吗」 | `buildbeat status --repo . --run <RUN>` | 一句:在跑第几步、跑了多久、历史通常多久、最后一次输出几分钟前;`STALLED` 就说「疑似卡住,建议停/等」;停在 kind `infra` 就说「worker 环境/后端故障,不是代码问题,恢复后我重跑,预算不扣」 |
34
- | 「批准 RUN-X」「拒绝,原因…」 | 先看 `inbox` 该 Run 等的是哪条 transition,再 `approve --transition <t> --by <用户名>` / `reject --reason`;非终态转换(`enter-fix` / `resume-<step>` / `enter-review`)批准后再 `resume --config <cfg>` 续跑 | 说清批的是哪一步:「放行 fixer,续跑中」/「再跑一次,续跑中」/「合并决定已落,候选 <sha> 具备合并条件;合并/push/部署要你另说」。`SUCCEEDED` 不等于已合并 |
34
+ | 「批准 RUN-X」「拒绝,原因…」 | 先看 `inbox` 该 Run 等的是哪条 transition,再 `approve --transition <t> --by <用户名>` / `reject --reason`;非终态转换(`enter-fix` / `resume-<step>` / `enter-review`)批准后再 `resume --config <cfg>` 续跑;用 `--attempt new` 自动编号时会选择该家族唯一未终态 Run,也可 `--run <RUN-ID>` 指定;多个候选或没有未终态 Run 时按报错处理 | 说清批的是哪一步:「放行 fixer,续跑中」/「再跑一次,续跑中」/「合并决定已落,候选 <sha> 具备合并条件;合并/push/部署要你另说」。`SUCCEEDED` 不等于已合并 |
35
35
  | 会话自己在 Run 的 worktree 里把 finding 修完并提交了(Run 停在 enter-fix / resume-fix) | `resume --config <cfg> --adopt <sha> --by <会话名>`(跳过 fixer,从 verify 续跑;树必须干净、HEAD 必须是该 sha) | 「我已手修并提交 <sha>,验证重跑中」;**不要**为了让 fixer 空跑而 approve enter-fix |
36
36
  | 「这条 finding 不算,那条接受」 | `findings list` / `findings adjudicate --action dismiss|accept` → `approve --transition enter-fix` | 裁决结果一句 |
37
37
  | 「上线」「做生产动作」 | 用 `release-readback` 预设 + `riskPreset: release` 开 Run:preflight 回读 → 停 `enter-apply-readback` | 「回读全绿,现在轮到你做〔动作〕;做完说一声」→ 用户说「做完了」→ `approve enter-apply-readback` → 回读+观察 → 停关窗 |
@@ -65,7 +65,8 @@ allowedPaths:
65
65
  - tests
66
66
  # P0/P1 先过人分诊再派 fixer
67
67
  reviewTriage: required
68
- # 可省;run 配置 > 预设 > 默认。预算耗尽停人时,批准 resume-<step> 即多给一次;
68
+ # 可省;run 配置 > 预设 > 默认。非只读步成功不扣次数;review 仍按轮计费。
69
+ # review 到顶时在 enter-fix 一次批准修复、重验、再审;Run/Work 上限同时放行;
69
70
  # reviewRoundsPerWork 跨本 Work 所有 Run 累计 review 轮数,超了新 Run 起跑前先问人
70
71
  budgets:
71
72
  maxAttempts:
@@ -126,7 +127,7 @@ workers:
126
127
  - workspace-write
127
128
  ```
128
129
 
129
- (严格 YAML 子集:只有块列表与块映射,没有行内 `[]` / `{}`,**注释必须独占一行**;上面这份可原样解析,机器验证在 `tests/v2-templates-firstrun.test.js`。**`fixer` 不能省**:没配它,verify 失败或 review 阻断时 Run 停 `WAITING_HUMAN` 等人手修,不会自动修。完整样板 [templates/v2/run-config.example.yaml](templates/v2/run-config.example.yaml),信封 [templates/v2/envelope/](templates/v2/envelope/worker.sh)。)通知通道另放 `.buildbeat/notify.yaml`(URL 只能来自环境变量),见 [docs/v2/guide/07-approval-guide.md](docs/v2/guide/07-approval-guide.md);指南索引 [docs/v2/guide/README.md](docs/v2/guide/README.md)。
130
+ (严格 YAML 子集:只有块列表与块映射,行内只允许空的 `[]` / `{}`,列表项可与键同缩进,**注释必须独占一行**;上面这份可原样解析,机器验证在 `tests/v2-templates-firstrun.test.js`。**`fixer` 不能省**:没配它,verify 失败或 review 阻断时 Run 停 `WAITING_HUMAN` 等人手修,不会自动修。完整样板 [templates/v2/run-config.example.yaml](templates/v2/run-config.example.yaml),信封 [templates/v2/envelope/](templates/v2/envelope/worker.sh)。)通知通道另放 `.buildbeat/notify.yaml`(URL 只能来自环境变量),见 [docs/v2/guide/07-approval-guide.md](docs/v2/guide/07-approval-guide.md);指南索引 [docs/v2/guide/README.md](docs/v2/guide/README.md)。
130
131
 
131
132
  **第一次为一个项目写 run-config 时,会话要多问用户一句**:「Run 停下来等你批、跑完、或疑似卡住时,要不要推到钉钉/webhook?给我一个只放在环境变量里的 URL 就行」——试点一直没启用通知,一张合并卡就绪后隔夜等了 9.5 小时。用户说不要就记一句「通知未启用,等待只在 inbox 里」。
132
133
 
@@ -239,7 +240,9 @@ worker prompt 里要写清三条环境事实(模板 AGENTS 第 ⑨ 条):沙箱
239
240
  | `controlled` | intent + plan 接受 + 合并决定 + 上线 | 契约变更、大改、不可逆副作用 |
240
241
  | `release` | 配 `release-readback` 预设:preflight 回读 → 人做 → apply 回读 → 关窗 | 生产动作 |
241
242
 
242
- 机器闸(gitleaks pre-commit)、证据制与合并候选一次核查任何预设都不跳;高风险 delta 不得借 `fast` 绕过独立核查。预算(每步 `maxAttempts`、Work 级 `reviewRoundsPerWork`)耗尽是停人不是失败,人批 `resume-<step>` 即多给一次;基础设施故障(超时 / 崩溃 / 非 JSON / exit 75)判 `infra` 停人、不派 fixer、不扣预算。
243
+ 机器闸(gitleaks pre-commit)、证据制与合并候选一次核查任何预设都不跳;高风险 delta 不得借 `fast` 绕过独立核查。预算耗尽是停人不是失败。非只读步(build / verify / fix)成功不扣 `maxAttempts`,只读步仍按尝试次数计费,review 按轮计费;基础设施故障(超时 / 崩溃 / 非 JSON / exit 75)判 `infra` 停人、不派 fixer、不扣预算。真失败到顶仍停 `resume-<step>`,批准多给一次。同一步总 attempt 达到有效预算上限(配置值 + 人批扩额)的 3 倍后,下一次执行前以 kind `budget` 兜底停人,防止成功循环失控;退款不抬高该兜底上限。
244
+
245
+ **一轮一问**:review 发现阻断问题且下一轮会超 Run 或 Work 上限时,提前停 `enter-fix`;有分诊用 `finding-triage`,无分诊用 `budget`。批准覆盖「修复 + 重新验证 + 再审一轮」,所需扩额随请求的可选 `grants` 落账,Run/Work 同时到顶只问一次;拒绝结束本 Run,由人按现有证据决定是否合并。批准旧的 `enter-review` / `resume-review` 预算停车时也同时放行已到顶的另一层上限。新候选或过期批准不能沿用旧请求的 grants。默认上限不变。
243
246
 
244
247
  ## 6. 三个仪式(防腐烂的关键,缺了机制必朽)
245
248
 
package/docs/README.md CHANGED
@@ -27,7 +27,7 @@
27
27
 
28
28
  | 类别 | 文件 |
29
29
  |---|---|
30
- | 发布证据 | `V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`(当前 `latest`)、`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`、`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`、`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`、`V2.0.0-BETA.1~5-RELEASE-EVIDENCE-*.md`、`V1.21-RELEASE-EVIDENCE-2026-08-25.md`、`WP4.3-RELEASE-EVIDENCE-2026-08-25.md` |
30
+ | 发布证据 | `V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`(当前 `latest`)、`V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`、`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`、`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`、`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`、`V2.0.0-BETA.1~5-RELEASE-EVIDENCE-*.md`、`V1.21-RELEASE-EVIDENCE-2026-08-25.md`、`WP4.3-RELEASE-EVIDENCE-2026-08-25.md` |
31
31
  | 规划与决策(2026-08) | [`V2-PLAN.md`](V2-PLAN.md)(执行基线,已交付)、[`V2-PROPOSAL.md`](V2-PROPOSAL.md)、[`V2-DECISIONS.md`](V2-DECISIONS.md)、[`V2-D2-DECISION-CARD.md`](V2-D2-DECISION-CARD.md)、[《BuildBeat v2:AI 原生软件交付控制平面》](BuildBeat%20v2%EF%BC%9AAI%20%E5%8E%9F%E7%94%9F%E8%BD%AF%E4%BB%B6%E4%BA%A4%E4%BB%98%E6%8E%A7%E5%88%B6%E5%B9%B3%E9%9D%A2.md) |
32
32
  | 迭代与里程碑记录 | `V2-ITERATION-01~08.md`、[`v2/`](v2/) 下的 M1/M2/M4 验收与试点记录 |
33
33
  | 早期版本史 | [`../CHANGELOG-v1.md`](../CHANGELOG-v1.md)(2026-06 ~ 2026-08 的条目原文;当前条目在根 [`CHANGELOG.md`](../CHANGELOG.md)) |
package/docs/RELEASING.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This runbook governs BuildBeat's public npm distribution. The canonical package is `@haiyangbg/buildbeat` in `HaiYangBG1/BuildBeat`; the only executable is `buildbeat`. No other package name or executable alias receives publications.
4
4
 
5
- Release evidence at source package version `@haiyangbg/buildbeat@3.0.1`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@3.0.0` (dist-tag `latest`; `next` stays `2.0.0-beta.5`), anchored by annotated tag `v3.0.0` at commit `0289415`, workflow run [34362068004](https://github.com/HaiYangBG1/BuildBeat/actions/runs/34362068004), and archived in [`V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`](V3.0.0-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.2 chain (`latest` from 2026-09-09 until 3.0.0 took over the same day; the last version carrying the removed generation) stays archived in [`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`](V2.0.2-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.1 chain (`latest` from 2026-09-06 until 2.0.2 took over on 2026-09-09) stays archived in [`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`](V2.0.1-RELEASE-EVIDENCE-2026-09-06.md). The 2.0.0 chain (`latest` from 2026-09-05 until 2.0.1 took over on 2026-09-06) stays archived in [`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`](V2.0.0-RELEASE-EVIDENCE-2026-09-05.md). The beta.5 chain stays archived in [`V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md`](V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md). The beta.4 chain stays archived in [`V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md`](V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md); the beta.3 chain in [`V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md`](V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md). The beta.2 chain stays archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md); the beta.1 chain stays archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). Earlier distributions and the retired legacy package name are archived in the dated evidence files under `docs/`.
5
+ Release evidence at source package version `@haiyangbg/buildbeat@3.1.0`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@3.0.1` (dist-tag `latest`; `next` stays `3.0.0`), anchored by annotated tag `v3.0.1` at commit `c322ce9`, workflow run [34370800960](https://github.com/HaiYangBG1/BuildBeat/actions/runs/34370800960), and archived in [`V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`](V3.0.1-RELEASE-EVIDENCE-2026-09-09.md). The 3.0.0 chain (`latest` from 2026-09-09 until 3.0.1 took over the same day; the first version without the removed generation) stays archived in [`V3.0.0-RELEASE-EVIDENCE-2026-09-09.md`](V3.0.0-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.2 chain (`latest` from 2026-09-09 until 3.0.0 took over the same day; the last version carrying the removed generation) stays archived in [`V2.0.2-RELEASE-EVIDENCE-2026-09-09.md`](V2.0.2-RELEASE-EVIDENCE-2026-09-09.md). The 2.0.1 chain (`latest` from 2026-09-06 until 2.0.2 took over on 2026-09-09) stays archived in [`V2.0.1-RELEASE-EVIDENCE-2026-09-06.md`](V2.0.1-RELEASE-EVIDENCE-2026-09-06.md). The 2.0.0 chain (`latest` from 2026-09-05 until 2.0.1 took over on 2026-09-06) stays archived in [`V2.0.0-RELEASE-EVIDENCE-2026-09-05.md`](V2.0.0-RELEASE-EVIDENCE-2026-09-05.md). The beta.5 chain stays archived in [`V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md`](V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md). The beta.4 chain stays archived in [`V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md`](V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md); the beta.3 chain in [`V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md`](V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md). The beta.2 chain stays archived in [`V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md); the beta.1 chain stays archived in [`V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md`](V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md). Earlier distributions and the retired legacy package name are archived in the dated evidence files under `docs/`.
6
6
 
7
7
  ## Channels and branches
8
8
 
@@ -35,7 +35,7 @@ The envelope must be committed: workers run in an isolated worktree and only see
35
35
 
36
36
  ## 2. Write the run config
37
37
 
38
- `delivery/work/WORK-DEMO-1/run-config.yaml`. Paths resolve relative to **this file**; the YAML is a strict subset: block lists and block maps only, no inline `[]` / `{}`, no anchors, comments on their own line. The config below parses as-is (machine-checked by `tests/v2-templates-firstrun.test.js`); the full sample and the envelope templates are in [`templates/v2/`](../../../templates/v2/run-config.example.yaml).
38
+ `delivery/work/WORK-DEMO-1/run-config.yaml`. Paths resolve relative to **this file**; the YAML is a strict subset: block lists and block maps only (list items may sit at their key's indentation), inline only for the empty `[]` / `{}`, no anchors, comments on their own line; quote a list item that contains `": "`. The config below parses as-is (machine-checked by `tests/v2-templates-firstrun.test.js`); the full sample and the envelope templates are in [`templates/v2/`](../../../templates/v2/run-config.example.yaml).
39
39
 
40
40
  ```yaml
41
41
  repo: ../../..
@@ -139,6 +139,8 @@ buildbeat approve --repo . --run RUN-DEMO-01 --transition enter-wait-merge --by
139
139
 
140
140
  **Be clear about which step you are approving** ([Approval guide](07-approval-guide.en.md)): `enter-wait-merge` is the merge decision; the Run reaches the terminal state `SUCCEEDED`, meaning the candidate is fit to merge. The actual merge, push and release are always your actions outside the Runner. `enter-fix` / `resume-<step>` are non-terminal transitions: after approving them, `resume --config …` lets the Run continue. When findings block, the Run routes fix → verify → review on its own; when the budget is exhausted or a failure fingerprint repeats, it stops and hands back to you ([Recovery](10-recovery.en.md)).
141
141
 
142
+ After `start --attempt new` numbers a Run, `resume --config <run-config.yaml>` resumes the family’s only non-terminal Run and prints its ID. Use `--run <RUN-ID>` to select the configured Run itself or `<family>-NN` explicitly (at least two digits). An existing ledger for the exact configured ID takes precedence. Multiple non-terminal Runs are listed with a request to select one using `--run`; if none remain, the error reports the latest ID and terminal status, or states that no ledgers were found.
143
+
142
144
  ## 7. Walk the failure branch once
143
145
 
144
146
  To see the automatic repair loop, commit a failing test case under `tests/` and `start --attempt new` again: verify fails → the fixer repairs with the failure summary → verify reruns → review. `status` shows `step fix: SUCCEEDED` and a second `verify`. To route findings through your hands first: `reviewTriage: required` together with `findings list` / `findings adjudicate` adjudicates fingerprint by fingerprint; a dismissed fingerprint no longer blocks.
@@ -35,7 +35,7 @@ git add delivery && git commit -qm "buildbeat: work WORK-DEMO-1 + envelope"
35
35
 
36
36
  ## 2. 写 run 配置
37
37
 
38
- `delivery/work/WORK-DEMO-1/run-config.yaml`。路径相对**本文件**解析;YAML 是严格子集:只有块列表与块映射,没有行内 `[]` / `{}`、没有锚点、注释必须独占一行。下面这份可以原样解析(机器验证在 `tests/v2-templates-firstrun.test.js`);完整样板与信封模板在 [`templates/v2/`](../../../templates/v2/run-config.example.yaml)。
38
+ `delivery/work/WORK-DEMO-1/run-config.yaml`。路径相对**本文件**解析;YAML 是严格子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 `[]` / `{}`,没有锚点,注释必须独占一行;含 `": "` 的列表项要加引号。下面这份可以原样解析(机器验证在 `tests/v2-templates-firstrun.test.js`);完整样板与信封模板在 [`templates/v2/`](../../../templates/v2/run-config.example.yaml)。
39
39
 
40
40
  ```yaml
41
41
  repo: ../../..
@@ -139,6 +139,8 @@ buildbeat approve --repo . --run RUN-DEMO-01 --transition enter-wait-merge --by
139
139
 
140
140
  **批的是哪一步要分清**([Approval 指南](07-approval-guide.md)):`enter-wait-merge` 是合并决定,Run 进终态 `SUCCEEDED`,表示候选具备合并条件——真正的合并、push、发布永远是你在 Runner 之外的动作;`enter-fix` / `resume-<step>` 是非终态转换,批准后要 `resume --config …` 让它续跑。被 findings 阻断时会自动路由 fix→verify→review 重走,超预算或失败指纹重复则停下交还给你([Recovery](10-recovery.md))。
141
141
 
142
+ 使用 `start --attempt new` 自动编号时,`resume --config <run-config.yaml>` 会续跑该家族唯一未终态的 Run,并打印选中的 ID;也可用 `--run <RUN-ID>` 显式指定配置中的 Run 本身或 `<家族>-NN`(数字至少两位)。配置本身已有台账时优先使用该精确 ID。多个未终态 Run 会列出候选并要求用 `--run` 选择;没有未终态 Run 会报告最新一次的 ID 和终态,没有台账则明确说明。
143
+
142
144
  ## 7. 走一次失败分支
143
145
 
144
146
  想看自动修复闭环,把一条会失败的用例提交进 `tests/`,再 `start --attempt new`:verify 失败 → fixer 带着失败摘要修 → verify 重跑 → review。`status` 会显示 `step fix: SUCCEEDED` 与第二次 `verify`。想让 finding 先过你的手:`reviewTriage: required` 配套 `findings list` / `findings adjudicate` 逐指纹裁决,dismiss 后同指纹不再阻断。
@@ -37,6 +37,17 @@ terminal:
37
37
 
38
38
  run 配置里 `entry` 可覆盖 workflow 的 `entry`(例如从 `build` 起步、跳过 intent/plan 步——digest 仍会绑进批准对象);`stopAt` 指定停点。workflow 文件整体做 sha256 → `RUN_CREATED.workflowDigest`,事后可证明当时跑的是哪份流程。
39
39
 
40
+ **写错时的报错形态**:`start` / `resume` / `doctor` / `preflight` / `approve --config` 读 run 配置时先整体校验,做任何事之前**一次列出全部问题**:
41
+
42
+ ```text
43
+ error: run config delivery/work/WORK-X/run-config.yaml has 3 problem(s):
44
+ - stopat: unknown key (did you mean stopAt?)
45
+ - repo: required and missing
46
+ - workers.reviwer: no step of the workflow uses this worker (did you mean reviewer?); workers in this workflow: planner, builder, verifier, reviewer, fixer
47
+ ```
48
+
49
+ 校验范围:必填键(`repo` `work` `run` `workflow` `workers`);未知顶层键与 worker / envelope 的未知字段(给最接近的拼写);类型与取值(正整数、列表、`inheritEnv` 只能是 `true` / `false`);worker 名必须被工作流步骤用到;`stopAt` / `entry` 必须是工作流步骤;`work` / `run` 只允许字母、数字、`.` `_` `-`,写成数字要加引号。
50
+
40
51
  run 配置还可声明(beta.3,皆来自三十轮部署战役的真实事故):
41
52
 
42
53
  - **`requires:` 环境契约**——信封隐式依赖的二进制与最低版本,Run 启动前 fail-closed 全量核验,一次报清所有问题(真实事故:`rg` 只在某会话 vendored PATH、`/bin/bash` 3.2、新 shell 解析到 Node 14,各烧掉整轮 Run 才见真因):
@@ -34,13 +34,15 @@ Sessions and documents used to mix "accept / approve / resume / succeeded / merg
34
34
  | Word | Command | Meaning | Is not |
35
35
  |---|---|---|---|
36
36
  | **Accept** | `accept --artifact intent\|plan` | A human endorses one artifact's digest; editing it makes the acceptance `stale` | Starting work; it creates no Run |
37
- | **Approve a transition** | `approve --transition <t>` | Lets the Run take **this one** transition: `enter-fix` (release the fixer after triage), `resume-<step>` (run the step once more after a budget or infra stop; records `BUDGET_EXTENDED`), `enter-review` (one more review round after the Work-level cap), `enter-apply-readback` ("I have done it" on the release lane) | Approving any other transition; after a non-terminal transition is approved the Run **does not move by itself**: `resume --config <run-config>` continues it (the `next:` line printed by `approve` says so) |
37
+ | **Approve a transition** | `approve --transition <t>` | Lets the Run take **this one** transition: `enter-fix` (release the fixer after triage; when the review budget is spent, this one approval also grants the next review round, and so does answering it with a hand fix via `resume --adopt <sha>`), `resume-<step>` (run the step once more after a budget or infra stop; records `BUDGET_EXTENDED`; successful build / verify / fix attempts are not charged, and a budget stop that hits both the run and the work review cap is lifted by one approval), `enter-review` (one more review round after the Work-level cap), `enter-apply-readback` ("I have done it" on the release lane) | Approving any other transition; after a non-terminal transition is approved the Run **does not move by itself**: `resume --config <run-config>` continues it (the `next:` line printed by `approve` says so) |
38
38
  | **Merge decision** (final approval) | `approve --transition enter-wait-merge` | The candidate is fit to merge: candidate + planDigest + evidenceDigest all hold at this instant; the Run reaches the terminal state `SUCCEEDED` and the run-record is compacted into the Git plane | Code merged, pushed or deployed: those three remain your actions outside the Runner, always |
39
39
  | **Run SUCCEEDED** | — | The Run stopped where it should and the evidence is complete | The Work is finished. `overview` shows `MERGED` only after reading back that the candidate is on the current branch |
40
40
  | **Reject** | `reject --reason` | The Run ends (`FAILED`, reason recorded) | The artifacts are invalidated; the acceptance state of intent/plan does not change |
41
41
 
42
42
  Likewise, `fix_now` on an observe draft is only acceptance; a human starts the Run. Protected actions are listed under [Security boundaries](09-security-boundaries.md) (Chinese).
43
43
 
44
+ After `start --attempt new` numbers a Run, `resume --config <run-config.yaml>` resumes the family’s only non-terminal Run and prints its ID. Use `--run <RUN-ID>` to select the configured Run itself or `<family>-NN` explicitly (at least two digits). An existing ledger for the exact configured ID takes precedence. Multiple non-terminal Runs are listed with a request to select one using `--run`; if none remain, the error reports the latest ID and terminal status, or states that no ledgers were found.
45
+
44
46
  ## Risk presets decide where humans approve
45
47
 
46
48
  `fast`: Merge only; `standard`: Plan + Merge; `controlled`: Intent + Plan + Merge + Release. A pending request must carry the findings summary and the reason, which prevents the "rubber stamp" decay; human waiting time goes into `metrics`.
@@ -102,7 +104,7 @@ Notification is not an approval channel: decisions are still made only through t
102
104
  When a Run stops at `enter-fix` / `resume-fix`, the driving session or a person has often already fixed the problem in the Run's worktree and committed it. Approving at that point dispatches a fixer with nothing to do and runs verify once more (a pilot frontend Run reached its 5th verify and 3rd fix this way). Use instead:
103
105
 
104
106
  ```bash
105
- buildbeat resume --config <run-config.yaml> --adopt <sha> --by <name>
107
+ buildbeat resume --config <run-config.yaml> --run <RUN-ID> --adopt <sha> --by <name>
106
108
  ```
107
109
 
108
110
  The kernel reads the worktree back: the tree must be clean and HEAD must be exactly `<sha>` (7-character prefix or longer), otherwise it refuses; then it records `CANDIDATE_PINNED` with a human actor (`adopted: true`), records `DECISION_RECORDED` with that commit as subject (`adopted`, `resumeAt`), and continues from verify (the step after a successful fix in the preset). The ledger shows who supplied this candidate. Adoption is not accepted at the merge decision.
@@ -34,17 +34,29 @@ buildbeat accept --repo . --work WORK-X --artifact plan --by <名字> # 工
34
34
  | 词 | 命令 | 含义 | 不等于 |
35
35
  |---|---|---|---|
36
36
  | **接受**(accept) | `accept --artifact intent\|plan` | 一份工件的 digest 被人认可;改过即 `stale` | 开工;不产生任何 Run |
37
- | **批准某转换**(approve) | `approve --transition <t>` | 允许 Run 走**这一条** transition:`enter-fix`(放行分诊后的 fixer)、`resume-<step>`(预算耗尽 / infra 停人后再跑一次,会落 `BUDGET_EXTENDED`)、`enter-review`(Work 级 review 上限后再审一轮)、`enter-apply-readback`(上线车道"我做完了") | 批准了别的转换;非终态转换批准后 Run **不会自己动**,要 `resume --config <run-config>` 续跑(`approve` 输出的 `next:` 行会写明) |
37
+ | **批准某转换**(approve) | `approve --transition <t>` | 允许 Run 走**这一条** transition:`enter-fix`(放行 fixer;附带 grants 时还放行重验后的下一轮 review)、`resume-<step>`(预算耗尽后扩额,或 infra 退款后重试)、`enter-review`(Work 级 review 上限后再审一轮)、`enter-apply-readback`(上线车道"我做完了") | 批准了别的转换;非终态转换批准后 Run **不会自己动**,要 `resume --config <run-config>` 续跑(`approve` 输出的 `next:` 行会写明) |
38
38
  | **合并决定**(最终批准) | `approve --transition enter-wait-merge` | 候选已具备合并条件:candidate + planDigest + evidenceDigest 此刻全部成立;Run 进终态 `SUCCEEDED`,run-record 压进 Git 面 | 代码已合并、已 push、已部署——这三件永远是你在 Runner 之外的动作 |
39
39
  | **Run SUCCEEDED** | — | Run 停在了它该停的地方,证据齐 | Work 完成。`overview` 只有回读到候选在当前分支上才显示 `MERGED` |
40
40
  | **拒绝**(reject) | `reject --reason` | Run 终止(`FAILED`,理由入账) | 工件失效;intent/plan 的接受状态不变 |
41
41
 
42
42
  同理 observe 草稿的 `fix_now` 只是接受,Run 由人发起。保护动作见 [安全边界](09-security-boundaries.md)。
43
43
 
44
+ 使用 `start --attempt new` 自动编号时,`resume --config <run-config.yaml>` 会续跑该家族唯一未终态的 Run,并打印选中的 ID;也可用 `--run <RUN-ID>` 显式指定配置中的 Run 本身或 `<家族>-NN`(数字至少两位)。配置本身已有台账时优先使用该精确 ID。多个未终态 Run 会列出候选并要求用 `--run` 选择;没有未终态 Run 会报告最新一次的 ID 和终态,没有台账则明确说明。
45
+
44
46
  ## 人批点由 Risk Preset 决定
45
47
 
46
48
  `fast` 仅 Merge;`standard` Plan+Merge;`controlled` Intent+Plan+Merge+Release。待批项强制携带 findings 摘要与理由——防"秒批"退化;人批等待时长进 `metrics`。
47
49
 
50
+ ## 预算停车:成功不扣次数,一轮一问
51
+
52
+ - 非只读步(build / verify / fix)成功的 attempt 不扣 `maxAttempts`,只读步仍消耗次数,review 按轮计费。`STEP_FINISHED.free: true` 与 `BUDGET_CONSUMED.amount: 0` 记录退款;没有新字段的旧台账保持原有回放结果。
53
+ - 真失败到顶仍停 `resume-<step>`,同指纹两次仍停,release 预设的 `maxAttempts: 1` 仍有效。infra 故障仍不扣次数。默认预算数值不变。
54
+ - review 发现阻断问题时,若下一轮会超过 Run 或 Work 上限,立即停 `enter-fix`,在花费修复、重验之前问一次。有分诊时 kind 为 `finding-triage`,无分诊时为 `budget`。批准表示「修复 + 重新验证 + 再审一轮」;拒绝结束本 Run,由人按现有证据决定是否合并。
55
+ - 每一次预算停车(`enter-fix`、`resume-<step>`、Work 级 `enter-review`)都在请求上记可选 `grants`,列出下一次执行会撞到的 Run/Work 上限;批准一次即同时放行两层,不再连问两次。`resume` 校验批准仍有效后逐条落 `BUDGET_EXTENDED`,并把整份放行计划钉在第一条上:放行落到一半进程被杀,再次 `resume` 按钉住的计划补齐剩余项,不从已被抬高的状态重算。过期批准、新请求不继承旧 grants。会话在 worktree 里手修并用 `resume --adopt <sha>` 回答该请求时,候选虽换成新提交,仍继承请求上的 grants(grants 属于这一轮,不属于某个候选;计划变了则不继承),修完重验后直接进入下一轮 review,不再二次停车。
56
+ - 防止自定义 workflow 的成功循环失控:同一步总 attempt 达到有效上限(配置预算 + 人批扩额)的 **3 倍**后,在下一次执行前仍以 kind `budget` 兜底停人。成功/infra 退款不增加兜底上限;批准扩额会提高它。
57
+
58
+ 停车首行显示已用次数(或 review 轮数)、有效上限与真失败次数,并保留 `budget` 标识供度量使用。`status` 的 attempt 序号和 `overview` 的成本累计仍表示实际运行量,不是失败次数;`doctor` 展示配置上限。
59
+
48
60
  ## 发现分诊门与锚定审查
49
61
 
50
62
  > 自 2.0.0-beta.3(beta.3)起。
@@ -102,7 +114,7 @@ buildbeat accept --repo . --work WORK-X --artifact plan --by <名字> # 工
102
114
  Run 停在 `enter-fix` / `resume-fix` 时,驾驶会话或人常常已经在 Run 的 worktree 里把问题修掉并提交了。此时再 `approve` 会派一个无事可做的 fixer,再多跑一次 verify(试点一条前端 Run 因此跑到 verify 第 5 次、fix 第 3 次)。改用:
103
115
 
104
116
  ```bash
105
- buildbeat resume --config <run-config.yaml> --adopt <sha> --by <名字>
117
+ buildbeat resume --config <run-config.yaml> --run <RUN-ID> --adopt <sha> --by <名字>
106
118
  ```
107
119
 
108
120
  内核回读 worktree:树必须干净、HEAD 必须就是 `<sha>`(前缀 7 位起),否则拒绝;然后以人为 actor 落 `CANDIDATE_PINNED`(`adopted: true`)、以该提交为 subject 记 `DECISION_RECORDED`(`adopted`、`resumeAt`),并从 verify 继续(预设里 fix 成功后的下一步)。台账里看得出这一版候选是谁供的。合并决定处不接受 adopt。
@@ -6,6 +6,10 @@ Design premise (invariant 23 in [`V2-PLAN.md`](../../V2-PLAN.md), Chinese): **th
6
6
 
7
7
  ## Symptom → action
8
8
 
9
+ ### "run config … has N problem(s)"
10
+
11
+ The run config has mistakes; no Run started and nothing changed. Fix the list item by item (each names the key, what is wrong and the closest valid spelling), then rerun the same command; `buildbeat doctor --config …` checks it on its own first.
12
+
9
13
  ### The ledger reports corrupted
10
14
 
11
15
  `status`/`inbox` shows `LEDGER CORRUPTED after seq=N (<reason>)`: the ledger truncates its view at the last valid event and **refuses to append**; recovery is a human decision, nothing is repaired silently.
@@ -20,19 +24,31 @@ Design premise (invariant 23 in [`V2-PLAN.md`](../../V2-PLAN.md), Chinese): **th
20
24
  buildbeat resume --config <run-config.yaml>
21
25
  ```
22
26
 
27
+ After `start --attempt new` numbers a Run, `resume --config <run-config.yaml>` resumes the family’s only non-terminal Run and prints its ID. Use `--run <RUN-ID>` to select the configured Run itself or `<family>-NN` explicitly (at least two digits). An existing ledger for the exact configured ID takes precedence. Multiple non-terminal Runs are listed with a request to select one using `--run`; if none remain, the error reports the latest ID and terminal status, or states that no ledgers were found.
28
+
23
29
  The in-flight step is closed as `crashed` (the fact is recorded), then **the step itself is rerun** (changed in beta.3): a dead process says nothing about the candidate; the lost attempt still counts against the step's budget, and an exhausted budget stops for a human. The earlier semantics treated a crash as a step failure and followed the failure edge; real incident (deploy-18): the host tool's timeout killed the verify worker, the crash was routed to fix, and the fixer burned a round facing zero verifier evidence. A dirty worktree still stops for a human first. Resuming with approvals re-checks candidate/plan freshness and turns `APPROVAL_STALE` over to a human if anything changed. If it cannot be recovered, delete the runtime and rerun: the candidate branch and the Git-plane records are not lost.
24
30
 
25
31
  **Launch discipline** (the other half of the same incident): a Run longer than minutes must be launched in a way that escapes the host tool's timeout (`nohup`/`setsid`); `start` prints this reminder in an interactive shell.
26
32
 
27
33
  ### A stuck lock ("another run is active")
28
34
 
29
- A Run that exited abnormally may leave the repository lock behind. Once you have confirmed that no Run is really active:
35
+ Every lock records its owner (pid, host name, time acquired, command). When a driver is killed, ended by a host-tool timeout or lost to a reboot, its locks stay behind; the next `resume` / `stop` / `start` that needs the lock reclaims it automatically when the owner is **on this host and its process no longer exists**, printing `reclaimed stale lock <id> (owner pid … is gone)`. No file needs deleting by hand. After a killed driver, simply run:
30
36
 
31
37
  ```bash
32
- buildbeat stop --repo . --run RUN-X --reason "crashed; releasing lock"
38
+ buildbeat resume --config <run-config.yaml>
33
39
  ```
34
40
 
35
- `stop` records the terminal state and the reason; for a mere leftover lock you may also delete `.buildbeat/runtime/` and start over.
41
+ The kernel takes the crash-recovery path above (`RUN_INTERRUPTED`, then the interrupted step reruns); if you would rather not continue, `buildbeat stop --repo . --run RUN-X --reason "…"`.
42
+
43
+ Only three cases still answer `another run is active` / `already locked`, and the error names the owner:
44
+
45
+ - **The owner is alive**: another Run really is driving; wait for it, or end that process once you are sure it is stuck;
46
+ - **The owner is on another host** (a repository on a shared disk): deal with it on that machine; this host never reclaims it;
47
+ - **No owner record**: a lock left by an older buildbeat, or a crash in the instant of taking it; once no buildbeat process is running, delete the lock directory the error names.
48
+
49
+ ### Ledger "changed on disk since it was read"
50
+
51
+ `ledger for RUN-X changed on disk since it was read (another writer); re-read it and retry`: another session or process wrote the same Run's ledger just before you (two approvals at once, a `stop` racing a `resume`). The kernel refused this write; **the ledger was not changed and is not corrupted**. Run the same command again: it decides afresh on the current state (and may simply tell you it is already approved or terminal). Approve, reject, `--adopt`, `stop`, `resume` and supersede all read the ledger only after taking the Run lock, so ordinary use never meets this error; seeing it means there really was a concurrent operation.
36
52
 
37
53
  ### Abnormal worker behaviour
38
54
 
@@ -78,6 +94,6 @@ Terminal Runs leave worktrees, `run/*` branches and the occasional lock. `buildb
78
94
  - It touches only Runs that are **terminal and already compacted into a run-record** (the Git plane must have the record before the runtime plane is touched);
79
95
  - Worktrees may be deleted (the commits are on the branch); a dirty worktree is left alone without `--force true`;
80
96
  - A branch is deleted only when the candidate **is reachable from another ref** (merged / tagged / on the remote) or the Run produced no candidate; otherwise it reports "reachable only from this branch, kept": that branch is the last thread to the evidence;
81
- - Leftover `locks/<RUN>.lock` of terminal Runs are cleaned; the `active-run` lock is still handled by hand as above.
97
+ - Leftover `locks/<RUN>.lock` of terminal Runs are cleaned; the `active-run` lock is reclaimed too when its owner process is gone (the plan names the owner), and kept with the reason when the owner is alive, on another host, or unrecorded.
82
98
 
83
99
  gc never writes to the ledger (after the terminal state only `RUN_COMPACTED` is allowed), so it can run at any time and repeatedly.
@@ -6,6 +6,10 @@
6
6
 
7
7
  ## 症状 → 处置
8
8
 
9
+ ### 「run config … has N problem(s)」
10
+
11
+ run 配置写错了,Run 没有起跑、什么都没改。按清单逐条改(每条写明是哪个键、错在哪、最接近的正确拼写),改完再跑同一条命令;`buildbeat doctor --config …` 可以先单独核对。
12
+
9
13
  ### 台账报 corrupted
10
14
 
11
15
  `status`/`inbox` 出现 `LEDGER CORRUPTED after seq=N (<原因>)`:台账在最后一条合法事件处截断视图并**拒绝追加**——恢复是人的决定,不静默修复。
@@ -20,19 +24,31 @@
20
24
  buildbeat resume --config <run-config.yaml>
21
25
  ```
22
26
 
27
+ 使用 `start --attempt new` 自动编号时,`resume --config <run-config.yaml>` 会续跑该家族唯一未终态的 Run,并打印选中的 ID;也可用 `--run <RUN-ID>` 显式指定配置中的 Run 本身或 `<家族>-NN`(数字至少两位)。配置本身已有台账时优先使用该精确 ID。多个未终态 Run 会列出候选并要求用 `--run` 选择;没有未终态 Run 会报告最新一次的 ID 和终态,没有台账则明确说明。
28
+
23
29
  在途步会以 `crashed` 关闭(事实落账),然后**重跑该步本身**(beta.3 改):进程死掉不说明候选有问题,丢失的那次尝试照常计入该步预算,预算耗尽即停人工。此前的语义是把 crash 当步骤失败走 failure 边——真实事故(deploy-18):宿主工具超时杀掉 verify worker,crash 被路由去 fix,fixer 面对零 verifier 证据白烧一轮。工作树脏了仍然先停人工。带批准恢复时会做 candidate/plan 新鲜度检查,变了即 `APPROVAL_STALE` 转人工。恢复不了就删 runtime 重跑——候选分支与 Git 面记录不丢。
24
30
 
25
31
  **启动纪律**(同一事故的另一半):长于分钟级的 Run 必须以脱离宿主工具超时的方式启动(`nohup`/`setsid`),交互式 shell 里 `start` 会打印这条提醒。
26
32
 
27
33
  ### 锁卡住("another run is active")
28
34
 
29
- 上一个 Run 异常退出可能留下仓库锁:确认真的没有活动 Run 后
35
+ 每把锁记录持有者(进程号、主机名、获取时间、命令)。驱动进程被杀、被宿主超时结束或机器重启后,锁会残留;下一次 `resume` / `stop` / `start` 拿锁时,若持有者**在本机且进程已不存在**,自动回收并打印 `reclaimed stale lock <id> (owner pid … is gone)`,无需手删任何文件。所以驱动被杀后直接:
30
36
 
31
37
  ```bash
32
- buildbeat stop --repo . --run RUN-X --reason "crashed; releasing lock"
38
+ buildbeat resume --config <run-config.yaml>
33
39
  ```
34
40
 
35
- `stop` 落终态与理由;单纯锁残留也可删 `.buildbeat/runtime/` 后重来。
41
+ 内核走上文的崩溃恢复(`RUN_INTERRUPTED` → 重跑中断步);不想续跑就 `buildbeat stop --repo . --run RUN-X --reason "…"`。
42
+
43
+ 只有三种情况仍会报 `another run is active` / `already locked`,报错里写明持有者:
44
+
45
+ - **持有者还活着**:另一个 Run 真的在跑,等它结束;确认它卡死再结束该进程;
46
+ - **持有者在别的主机**(共享盘上的仓库):到那台机器处理,本机不会替它回收;
47
+ - **没有持有者信息**:旧版本 buildbeat 留下的锁,或拿锁瞬间崩溃;确认没有 buildbeat 进程在跑后,删除报错里给出的那个锁目录。
48
+
49
+ ### 台账「changed on disk since it was read」
50
+
51
+ `ledger for RUN-X changed on disk since it was read (another writer); re-read it and retry`:另一个会话或进程刚好在你之前写了同一个 Run 的台账(例如两边同时批准、一边 `stop` 一边 `resume`)。内核拒绝了这次写入,**台账没有被改动、也没有损坏**;重新执行同一条命令即可,它会基于最新状态重新判断(可能直接告诉你「已经批过了 / 已终态」)。批准、拒绝、`--adopt`、`stop`、`resume` 与自动取代都在拿到 Run 锁之后才读台账,正常使用不会遇到这条报错;看到它说明确实有并发操作。
36
52
 
37
53
  ### Worker 行为异常
38
54
 
@@ -78,6 +94,6 @@ rm -rf .buildbeat/runtime/
78
94
  - 只动**终态且已压成 run-record** 的 Run(Git 面有账才动运行时面);
79
95
  - 工作树可删(提交都在分支上);脏工作树不带 `--force true` 不动;
80
96
  - 分支只在候选**已可从其他 ref 到达**(已合并 / 打 tag / 在远端)或 Run 未产出候选时删;否则明示"仅此分支可达,保留"——它是证据的最后一根线;
81
- - 终态 Run 的残留 `locks/<RUN>.lock` 一并清;`active-run` 锁仍按上文人工处置。
97
+ - 终态 Run 的残留 `locks/<RUN>.lock` 一并清;`active-run` 锁在持有者进程已不存在时一并回收(计划里写明持有者),持有者还活着 / 在别的主机 / 没有持有者信息时保留并说明原因。
82
98
 
83
99
  gc 永不写台账(终态后只允许 `RUN_COMPACTED`),所以随时可跑、可重复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haiyangbg/buildbeat",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "BuildBeat: Git-based AI delivery across models, tools, sessions, and people, with context in project files, build-test-review-fix loops, and human decisions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,223 @@
1
+ // Run-config validation. The loader used to accept almost anything: a
2
+ // missing `repo` surfaced as Node's "paths[1] must be of type string", a
3
+ // misspelled key (`stopat`) or worker (`reviwer`) was silently ignored, and
4
+ // `inheritEnv: yes` quietly meant false. Run configs are mostly written by
5
+ // AI sessions, so every problem is reported at once, naming the key, what is
6
+ // wrong and the closest valid spelling, before anything runs.
7
+
8
+ const TOP_KEYS = [
9
+ "repo",
10
+ "work",
11
+ "run",
12
+ "workflow",
13
+ "riskPreset",
14
+ "base",
15
+ "entry",
16
+ "stopAt",
17
+ "stepTimeoutMs",
18
+ "maxAttemptsPerStep",
19
+ "allowedPaths",
20
+ "budgets",
21
+ "cache",
22
+ "envelope",
23
+ "requires",
24
+ "workers",
25
+ "policies",
26
+ "redact",
27
+ "reviewTriage",
28
+ "supersede",
29
+ "stallAfterMs",
30
+ ];
31
+ const REQUIRED = ["repo", "work", "run", "workflow"];
32
+ const WORKER_KEYS = ["command", "args", "timeoutMs", "inheritEnv", "env"];
33
+ const ENVELOPE_KEYS = ["prompts", "vars", "pin"];
34
+ const LISTS = ["stopAt", "allowedPaths", "policies", "redact", "requires"];
35
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
36
+
37
+ export class RunConfigError extends Error {
38
+ constructor(label, problems) {
39
+ super(`run config ${label} has ${problems.length} problem(s):\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
40
+ this.name = "RunConfigError";
41
+ this.problems = problems;
42
+ }
43
+ }
44
+
45
+ function isMap(value) {
46
+ return value !== null && typeof value === "object" && !Array.isArray(value);
47
+ }
48
+
49
+ function distance(a, b) {
50
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
51
+ for (let i = 1; i <= a.length; i += 1) {
52
+ let previous = row[0];
53
+ row[0] = i;
54
+ for (let j = 1; j <= b.length; j += 1) {
55
+ const kept = row[j];
56
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
57
+ previous = kept;
58
+ }
59
+ }
60
+ return row[b.length];
61
+ }
62
+
63
+ // The closest known name, if it is plausibly what was meant.
64
+ export function suggest(name, known) {
65
+ const lower = String(name).toLowerCase();
66
+ const exact = known.find((candidate) => candidate.toLowerCase() === lower);
67
+ if (exact) {
68
+ return exact;
69
+ }
70
+ let best = null;
71
+ let bestDistance = 3;
72
+ for (const candidate of known) {
73
+ const d = distance(lower, candidate.toLowerCase());
74
+ if (d < bestDistance) {
75
+ best = candidate;
76
+ bestDistance = d;
77
+ }
78
+ }
79
+ return best;
80
+ }
81
+
82
+ function unknownKey(where, key, known) {
83
+ const guess = suggest(key, known);
84
+ return `${where}${key}: unknown key${guess ? ` (did you mean ${guess}?)` : `; known: ${known.join(", ")}`}`;
85
+ }
86
+
87
+ function positiveInteger(value) {
88
+ return Number.isInteger(value) && value > 0;
89
+ }
90
+
91
+ function nonEmptyString(value) {
92
+ return typeof value === "string" && value.trim() !== "";
93
+ }
94
+
95
+ // First pass: everything that does not need the workflow. Runs right after
96
+ // parsing, so a missing `repo` is reported by name instead of crashing path
97
+ // resolution.
98
+ export function checkRunConfigShape(config) {
99
+ if (!isMap(config)) {
100
+ return ["the file must be a map of keys (repo, work, run, workflow, workers, ...)"];
101
+ }
102
+ const problems = [];
103
+ for (const key of Object.keys(config)) {
104
+ if (!TOP_KEYS.includes(key)) {
105
+ problems.push(unknownKey("", key, TOP_KEYS));
106
+ }
107
+ }
108
+ for (const key of REQUIRED) {
109
+ if (config[key] === undefined || config[key] === null) {
110
+ problems.push(`${key}: required and missing`);
111
+ }
112
+ }
113
+ // An explicit null (key: null, key: ~) is never "use the default": say so
114
+ // instead of silently falling back.
115
+ for (const [key, value] of Object.entries(config)) {
116
+ if (value === null && TOP_KEYS.includes(key) && !REQUIRED.includes(key)) {
117
+ problems.push(`${key}: has no value; remove the line to use the default, or give it a value`);
118
+ }
119
+ }
120
+ for (const key of ["repo", "workflow", "riskPreset", "base", "entry"]) {
121
+ const value = config[key];
122
+ if (value !== undefined && value !== null && !nonEmptyString(value)) {
123
+ problems.push(`${key}: must be a non-empty string, got ${JSON.stringify(value)}`);
124
+ }
125
+ }
126
+ for (const key of ["work", "run"]) {
127
+ const value = config[key];
128
+ if (value === undefined || value === null) {
129
+ continue;
130
+ }
131
+ if (typeof value !== "string") {
132
+ problems.push(`${key}: must be a string, got ${JSON.stringify(value)} (quote it, e.g. ${key}: "${value}")`);
133
+ } else if (!ID.test(value) || value.includes("..") || value.length > 100) {
134
+ problems.push(`${key}: "${value}" may only use letters, digits, ".", "_" and "-", start with a letter or digit, contain no "..", and be at most 100 characters (it becomes part of paths and branch names)`);
135
+ }
136
+ }
137
+ for (const key of ["stepTimeoutMs", "maxAttemptsPerStep"]) {
138
+ if (config[key] !== undefined && !positiveInteger(config[key])) {
139
+ problems.push(`${key}: must be a positive integer, got ${JSON.stringify(config[key])}`);
140
+ }
141
+ }
142
+ for (const key of LISTS) {
143
+ if (config[key] !== undefined && !Array.isArray(config[key])) {
144
+ problems.push(`${key}: must be a list (one "- item" per line), got ${JSON.stringify(config[key])}`);
145
+ }
146
+ }
147
+ if (Array.isArray(config.allowedPaths)) {
148
+ config.allowedPaths.forEach((entry, index) => {
149
+ if (!nonEmptyString(entry)) {
150
+ problems.push(`allowedPaths[${index}]: must be a non-empty path, got ${JSON.stringify(entry)}`);
151
+ }
152
+ });
153
+ }
154
+ if (config.envelope !== undefined) {
155
+ if (!isMap(config.envelope)) {
156
+ problems.push("envelope: must be a map (prompts, vars, pin)");
157
+ } else {
158
+ for (const key of Object.keys(config.envelope)) {
159
+ if (!ENVELOPE_KEYS.includes(key)) {
160
+ problems.push(unknownKey("envelope.", key, ENVELOPE_KEYS));
161
+ }
162
+ }
163
+ }
164
+ }
165
+ if (config.workers === undefined || config.workers === null) {
166
+ problems.push("workers: required and missing (at least the verifier; see templates/v2/run-config.example.yaml)");
167
+ } else if (!isMap(config.workers)) {
168
+ problems.push("workers: must be a map of worker name -> { command, args, ... }");
169
+ } else {
170
+ for (const [name, spec] of Object.entries(config.workers)) {
171
+ const where = `workers.${name}`;
172
+ if (!isMap(spec)) {
173
+ problems.push(`${where}: must be a map with at least a command`);
174
+ continue;
175
+ }
176
+ for (const key of Object.keys(spec)) {
177
+ if (!WORKER_KEYS.includes(key)) {
178
+ problems.push(unknownKey(`${where}.`, key, WORKER_KEYS));
179
+ }
180
+ }
181
+ if (!nonEmptyString(spec.command)) {
182
+ problems.push(`${where}.command: required, a non-empty string`);
183
+ }
184
+ if (spec.args !== undefined && !Array.isArray(spec.args)) {
185
+ problems.push(`${where}.args: must be a list (one "- arg" per line)`);
186
+ }
187
+ if (spec.timeoutMs !== undefined && !(typeof spec.timeoutMs === "number" && spec.timeoutMs > 0)) {
188
+ problems.push(`${where}.timeoutMs: must be a positive number, got ${JSON.stringify(spec.timeoutMs)}`);
189
+ }
190
+ if (spec.inheritEnv !== undefined && typeof spec.inheritEnv !== "boolean") {
191
+ problems.push(`${where}.inheritEnv: must be true or false, got ${JSON.stringify(spec.inheritEnv)} (anything else used to mean false silently)`);
192
+ }
193
+ if (spec.env !== undefined && !isMap(spec.env)) {
194
+ problems.push(`${where}.env: must be a map of NAME -> value`);
195
+ }
196
+ }
197
+ }
198
+ return problems;
199
+ }
200
+
201
+ // Second pass, once the workflow is loaded: names that must exist in it.
202
+ export function checkRunConfigAgainstWorkflow(config, workflow) {
203
+ const problems = [];
204
+ const steps = [...workflow.stepIds];
205
+ const workers = [...new Set(workflow.steps.map((step) => step.worker).filter(Boolean))];
206
+ for (const name of Object.keys(isMap(config.workers) ? config.workers : {})) {
207
+ if (!workers.includes(name)) {
208
+ const guess = suggest(name, workers);
209
+ problems.push(`workers.${name}: no step of the workflow uses this worker${guess ? ` (did you mean ${guess}?)` : ""}; workers in this workflow: ${workers.join(", ")}`);
210
+ }
211
+ }
212
+ for (const step of Array.isArray(config.stopAt) ? config.stopAt : []) {
213
+ if (!workflow.stepIds.has(step)) {
214
+ const guess = suggest(step, steps);
215
+ problems.push(`stopAt: "${step}" is not a step of the workflow${guess ? ` (did you mean ${guess}?)` : ""}; steps: ${steps.join(", ")}`);
216
+ }
217
+ }
218
+ if (config.entry !== undefined && config.entry !== null && !workflow.stepIds.has(config.entry)) {
219
+ const guess = suggest(config.entry, steps);
220
+ problems.push(`entry: "${config.entry}" is not a step of the workflow${guess ? ` (did you mean ${guess}?)` : ""}; steps: ${steps.join(", ")}`);
221
+ }
222
+ return problems;
223
+ }