@haiyangbg/buildbeat 4.1.0 → 4.2.1
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 +16 -0
- package/README.en.md +7 -5
- package/README.md +7 -5
- package/SKILL.md +6 -3
- package/docs/CAPABILITY-MATRIX.md +2 -2
- package/docs/RELEASING.md +1 -1
- package/docs/v2/guide/05-worker-contract.en.md +2 -0
- package/docs/v2/guide/05-worker-contract.md +1 -1
- package/docs/v2/guide/07-approval-guide.en.md +11 -0
- package/docs/v2/guide/07-approval-guide.md +11 -0
- package/docs/v2/guide/10-recovery.en.md +4 -0
- package/docs/v2/guide/10-recovery.md +4 -0
- package/package.json +1 -1
- package/src/v2/cli/run.js +35 -8
- package/src/v2/domain/model.js +1 -1
- package/src/v2/engine/reducer.js +2 -1
- package/src/v2/runtime/decisions.js +75 -6
- package/src/v2/runtime/notify.js +28 -5
- package/src/v2/runtime/orchestrator.js +41 -2
- package/src/v2/runtime/overview.js +1 -1
- package/src/v2/runtime/work-cost.js +70 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
> 本项目吃自己的狗粮(红线④:必更 CHANGELOG)。格式循 Keep a Changelog,倒序。
|
|
4
4
|
|
|
5
|
+
## v4.2.1 — 2026-10-07(补丁:审查没交报告当场停;成本按去向分开等待)
|
|
6
|
+
|
|
7
|
+
- A review step that exits 0 without writing a report to `$BUILDBEAT_OUTPUT` now stops as `invalid-output` (an infrastructure failure: no fixer, no charge, `resume-review` waits for a person) instead of passing with no evidence until the merge approval refused it. The stop reason says where the report belongs and, when the reviewer printed something, how many bytes went to stdout. Found in a replay of real runs, where a reviewer's 3 P1, 6 P2 and 4 P3 findings reached stdout only.
|
|
8
|
+
- The cost line splits human waits by how they ended: `human waits N (decided … · superseded … · stopped … · open …)`, listing only kinds that occurred; a request repeated before any decision continues the same wait, so overlapping time is not counted twice; JSON carries `cost.waits` with `{count, ms}` per kind and run-records keep it. In the same replay one work showed 32 minutes of decided waits while 11 hours passed on a request a new run superseded.
|
|
9
|
+
- README (zh/en) describes the 4.x product: the three promises now name the mechanisms behind them (one `work.md` driven by `buildbeat run` and `status --all-repos`; evidence read back from Git and infrastructure stops instead of code fixes; merge approvals bound to the candidate and repairs at the merge decision), the cost line's waits, taking part without the runtime and the release readback.
|
|
10
|
+
|
|
11
|
+
## v4.2.0 — 2026-10-07(次版本:合并决定点能接修复;合并批准绑定候选)
|
|
12
|
+
|
|
13
|
+
> **发布状态**:`@haiyangbg/buildbeat@4.2.0` 已于 2026-10-07 从 `main`(release PR #76,merge commit `cc52ad3`,tag `v4.2.0`)经 OIDC Trusted Publishing 发布到 dist-tag **`latest`**(run 37597691057,publish 与 verify 双 job 一次 success;所有者授权「推送并发 4.2.0」)。独立回读(直连 npmjs.org):`latest` = 4.2.0、`dist.integrity` 与本地用 Node 24 + npm 11.19.0 打的候选逐字一致、注册表 tarball 解压后与候选逐字节相同、SLSA v1 provenance、隔离安装 `--version` = 4.2.0、裸调用零写入、用法含 `--candidate <sha>` 与 `--action fix`、`npm audit signatures` 通过;GitHub Release v4.2.0 标 Latest;证据见 [`docs/releases/V4.2.0-RELEASE-EVIDENCE-2026-10-07.md`](docs/releases/V4.2.0-RELEASE-EVIDENCE-2026-10-07.md)。合并后 `main` CI 第 1 次尝试因跨仓测试快照混入 git 后台维护的锁文件失败、重跑转绿后才批准发布,测试修复随收尾合入。同日所有者本人经 npm 两步验证把 dist-tag `next` 从 4.0.0 挪到 4.2.0;直连 npmjs.org 回读 `next` = `latest` = 4.2.0。
|
|
14
|
+
|
|
15
|
+
- Bind merge approval replies to `--candidate <sha>` and forward the binding through the CLI; reject old or unbound replies after repair. Support both repair paths when the current candidate is still the base commit. Isolate repair regression fixtures from host Git configuration, signing and hooks. The owner confirmed the public name `--candidate <sha>` and runtime 4.2 as the next minor on 2026-10-07; merge-decision `run --adopt`, `decide --action fix` and candidate binding require runtime 4.2 or later.
|
|
16
|
+
- Allow manual repair adoption at the merge decision: require clean actual HEAD, a new descendant of the candidate and changes within `allowedPaths`; resume at verify, review incrementally and return to the merge decision while preserving evidence and review accounting.
|
|
17
|
+
- Add `decide --action fix --reason <text>` at the merge decision when a fixer is configured. Record an accepted P1 with the human's reason, feed it to the fixer and continue through verify/review without spending a review round for the decision or changing budgets. Status, inbox and notifications offer manual repair and the configured fixer path. SUCCEEDED and legacy 3.x Runs retain their existing boundaries.
|
|
18
|
+
|
|
5
19
|
## v4.1.0 — 2026-10-07(次版本:跨仓总览;提示里的命令与路径都能照做)
|
|
6
20
|
|
|
21
|
+
> **发布状态**:`@haiyangbg/buildbeat@4.1.0` 已于 2026-10-07 从 `main`(release PR #74,merge commit `9259ac9`,tag `v4.1.0`)经 OIDC Trusted Publishing 发布到 dist-tag **`latest`**(run 37590027989,publish 与 verify 双 job 一次 success;所有者授权「推送并发 4.1.0」)。独立回读(直连 npmjs.org):`latest` = 4.1.0、`dist.integrity` 与本地用 Node 24 + npm 11.19.0 打的候选逐字一致、注册表 tarball 解压后与候选逐字节相同、SLSA v1 provenance、隔离安装 `--version` = 4.1.0、裸调用零写入、用法含 `--all-repos`、`npm audit signatures` 通过;GitHub Release v4.1.0 标 Latest;本机两份全局安装均为 4.1.0;证据见 [`docs/releases/V4.1.0-RELEASE-EVIDENCE-2026-10-07.md`](docs/releases/V4.1.0-RELEASE-EVIDENCE-2026-10-07.md)。`next` 仍为 4.0.0。
|
|
22
|
+
|
|
7
23
|
- Add read-only `status --all-repos`: discover the main repository, configured target checkouts and immediate child repositories, deduplicate real paths, show pending decisions first and count settled work. JSON retains all work; `--work` filters across repositories, while `--run` requires single-repository status.
|
|
8
24
|
- Fix main-repository status/overview reporting target-repository work as ready to start: read state, findings and decisions from the target, retain the main run configuration for commands, and quote paths relative to the caller. Missing/invalid targets warn without hiding other work. Ship `src/v2/runtime/overview-repos.js` and update the package-file inventory for this runtime helper.
|
|
9
25
|
- Hints give commands that work as written. A merged work whose run configs were all read and none has a `release:` section is told there is nothing left to do, instead of being sent to `buildbeat release`, which fails without that section and left the work unable to close; with a `release:` section the hint names the config that has it. A waiting run with no reply commands points to `status --work`, and `check` names `status` instead of the 3.x `inbox`. The run config template comment (and its copies in the Skill, both quickstarts and the example) names `check` / `run` / `--new`, and the quickstart says where the npm package installs the templates. Found by a first run of a fresh project on 4.0.0 with real codex workers.
|
package/README.en.md
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
**Switch sessions. Keep building.**
|
|
4
4
|
|
|
5
|
-
BuildBeat
|
|
5
|
+
BuildBeat is for humans and AI sessions: the work description and every record stay in project files and Git; implementation, verification, review and repair move forward outside the session and can always resume; the candidate waits with its evidence for your merge decision.
|
|
6
6
|
|
|
7
7
|
## Three promises
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **Walk away.** Each work gets one `work.md`; once accepted, `buildbeat run` drives it. Close the session, switch AI tools or hand over to someone else, and continue from the project files and the run ledger. In a main repository with several code repositories, `buildbeat status --repo . --all-repos` shows what is pending everywhere.
|
|
10
|
+
- **Come back to facts.** Verification runs the project's own commands, and the runtime reads results back from Git and the ledger instead of trusting what an AI says; review is independent and read-only. Missing tools, an unavailable backend or a review that hands in no report stop as infrastructure failures for a person, instead of being "fixed" as code.
|
|
11
|
+
- **Decide with confidence.** A merge approval is bound to the candidate you reviewed (`--candidate`). When you spot a small problem at the merge decision, commit a fix and hand it over (`run --adopt`) or send it back to the fixer in one line (`decide --action fix`), without starting a new run. Merge, push, release and deployment stay with people.
|
|
10
12
|
|
|
11
13
|
## Start
|
|
12
14
|
|
|
@@ -26,13 +28,13 @@ buildbeat status --repo . --work WORK-X
|
|
|
26
28
|
|
|
27
29
|
## Records and handoffs
|
|
28
30
|
|
|
29
|
-
End-to-end work packages contain work.md, configuration, decisions and terminal records under delivery/work/. Local in-flight events and logs live in .buildbeat/runtime/; candidates live in .buildbeat/worktrees/. Save missing facts before switching sessions
|
|
31
|
+
End-to-end work packages contain work.md, configuration, decisions, review findings and terminal records under delivery/work/. Local in-flight events and logs live in .buildbeat/runtime/; candidates live in .buildbeat/worktrees/. The cost line in `status` shows review rounds, findings and where human waits went (decided, superseded, stopped, still open). Save missing facts before switching sessions; active runs do not migrate through Git clone. People and tools without the runtime can still read status and write work descriptions.
|
|
30
32
|
|
|
31
|
-
[Handoff](docs/v2/guide/11-session-handoff.en.md) · [Recovery](docs/v2/guide/10-recovery.en.md)
|
|
33
|
+
[Handoff](docs/v2/guide/11-session-handoff.en.md) · [Recovery](docs/v2/guide/10-recovery.en.md) · [Without the runtime](docs/v2/guide/12-without-runtime.en.md)
|
|
32
34
|
|
|
33
35
|
## Boundaries
|
|
34
36
|
|
|
35
|
-
BuildBeat does not provide multi-user accounts, roles and permissions. It does not collect or upload project usage data and has no telemetry collection. Models and authentication belong to the configured AI tool. Merge, push, deployment and publication remain separately authorized external actions
|
|
37
|
+
BuildBeat does not provide multi-user accounts, roles and permissions. It does not collect or upload project usage data and has no telemetry collection. Models and authentication belong to the configured AI tool. Merge, push, deployment and publication remain separately authorized external actions; after a release, `buildbeat release` records the project's own readback and the window closes only when it passes. Production monitoring and arbitrary workflow/policy languages are outside the product.
|
|
36
38
|
|
|
37
39
|
[Capabilities](docs/CAPABILITY-MATRIX.md) · [Security](docs/v2/guide/09-security-boundaries.en.md) · [Migration](docs/MIGRATION.md)
|
|
38
40
|
|
package/README.md
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
**会话随时换,项目接着干。**
|
|
4
4
|
|
|
5
|
-
BuildBeat 面向人和 AI
|
|
5
|
+
BuildBeat 面向人和 AI 会话:工作说明和全部记录留在项目文件与 Git 里,实现、验证、审查和修复在会话之外自动推进、随时可恢复,候选带着证据停在你的合并决定前。
|
|
6
6
|
|
|
7
7
|
## 三个承诺
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **走得开**:一项工作写一份 `work.md`,接受后由 `buildbeat run` 推进。关掉会话、换 AI 工具、换人,都能从项目文件和运行台账接着干;主仓带多个代码仓时,`buildbeat status --repo . --all-repos` 一条命令看全所有仓的待办。
|
|
10
|
+
- **回来能信**:验证跑项目自己的命令,结果由运行时从 Git 和台账回读,不听 AI 自述;审查独立、只读。缺工具、后端不可用、审查没交报告,都按环境故障停下等人,不会把环境问题当成代码问题去修。
|
|
11
|
+
- **敢拍板**:合并批准绑定你看过的那一版候选(`--candidate`)。在合并决定点发现小问题,可以自己修好交回(`run --adopt`),或一句话退回给修复者(`decide --action fix`),不必重开一轮。合并、推送、发布、部署始终由人决定。
|
|
10
12
|
|
|
11
13
|
## 开始使用
|
|
12
14
|
|
|
@@ -26,13 +28,13 @@ buildbeat status --repo . --work WORK-X
|
|
|
26
28
|
|
|
27
29
|
## 记录与接续
|
|
28
30
|
|
|
29
|
-
端到端工作包在 delivery/work/ 保存 work.md
|
|
31
|
+
端到端工作包在 delivery/work/ 保存 work.md、配置、决定、审查问题与终态记录;本机在途事件和日志在 .buildbeat/runtime/,候选在 .buildbeat/worktrees/。`status` 的成本行列出审查轮数、问题数,以及人工等待各自的去向(已决定、被替代、被停掉、仍在等)。换会话前补齐未落盘事实;Git clone 不迁移活动进程和现场。没装运行时的人和工具也能读状态、写工作说明。
|
|
30
32
|
|
|
31
|
-
[Handoff](docs/v2/guide/11-session-handoff.md) · [Recovery](docs/v2/guide/10-recovery.md)
|
|
33
|
+
[Handoff](docs/v2/guide/11-session-handoff.md) · [Recovery](docs/v2/guide/10-recovery.md) · [没有运行时](docs/v2/guide/12-without-runtime.md)
|
|
32
34
|
|
|
33
35
|
## 能力边界
|
|
34
36
|
|
|
35
|
-
BuildBeat 不提供多人账号、角色/权限系统,不采集或上传项目使用数据,没有遥测采集。模型和鉴权由配置的 AI
|
|
37
|
+
BuildBeat 不提供多人账号、角色/权限系统,不采集或上传项目使用数据,没有遥测采集。模型和鉴权由配置的 AI 工具提供。合并、推送、部署、发布分别由获授权的外部操作完成;上线后可用 `buildbeat release` 记录项目自己的回读,回读通过才关窗。生产巡检和任意工作流/规则语言不在产品范围内。
|
|
36
38
|
|
|
37
39
|
[Capabilities](docs/CAPABILITY-MATRIX.md) · [Security](docs/v2/guide/09-security-boundaries.md) · [Migration](docs/MIGRATION.md)
|
|
38
40
|
|
package/SKILL.md
CHANGED
|
@@ -11,11 +11,13 @@ description: BuildBeat 用项目文件接续上下文,在隔离工作树内自
|
|
|
11
11
|
|
|
12
12
|
先跑 `buildbeat --version`,按结果选命令:
|
|
13
13
|
|
|
14
|
-
- 4.x
|
|
14
|
+
- 4.x:按下文操作,但合并决定点的两种修复入口有下述版本限制。
|
|
15
15
|
- 3.x:下文的 `run`、`status --work`、`decide`、`check`、`history` 和 `work.md` 在 3.x 不存在,沿用项目现有的 3.x 写法:`intent.md` + `plan.md`,分别 `accept --artifact intent` 与 `accept --artifact plan`;`start --config <config> --attempt new` 开工,`resume --config <config> [--run <RUN>] [--adopt <sha> --by <name>]` 续跑;`overview`、`inbox`、`status --run <RUN>` 看进度;`approve --transition <t>` / `reject --reason <why>` 决定;`findings adjudicate` 裁决问题;`doctor` 检查配置;run-config 保留 `workflow:` 与 `riskPreset:`。
|
|
16
16
|
- 3.x 的活动 Run 只能用 3.x 运行时完成或取消。升级到 4.x 前先问用户,并按[迁移说明](docs/MIGRATION.md)处理。
|
|
17
17
|
- 没有运行时:按[不装运行时也能参与](docs/v2/guide/12-without-runtime.md)只读状态、写 `work.md` 和规定格式的决定行;不得代批 Run,不得声称验证、审查或回读已通过。
|
|
18
18
|
|
|
19
|
+
合并决定点的 `run --adopt`、`decide --action fix` 与批准命令的 `--candidate` 候选绑定要求运行时 4.2 或更高版本。
|
|
20
|
+
|
|
19
21
|
## 会话操作
|
|
20
22
|
|
|
21
23
|
| 用户意图 | 操作 |
|
|
@@ -24,9 +26,10 @@ description: BuildBeat 用项目文件接续上下文,在隔离工作树内自
|
|
|
24
26
|
| 准备一项工作 | 写 `delivery/work/<ID>/work.md`,包含目标、范围、验收、实施计划;复制 run-config 样板并填真实命令 |
|
|
25
27
|
| 接受工作说明 | `buildbeat accept --repo . --work <ID> --by <owner>`;用户已有明确授权时直接记录,不重复问同一个决定 |
|
|
26
28
|
| 开工、继续 | `buildbeat run --config <config>`;首次编号,已有运行则恢复;明确新一轮时加 `--new` |
|
|
27
|
-
| 批准、拒绝 |
|
|
29
|
+
| 批准、拒绝 | 读取状态卡并复制批准或拒绝命令;合并批准为 `buildbeat decide --repo . --run <RUN> --action approve --transition enter-wait-merge --candidate <完整 SHA> --by <owner>`,使用卡片候选;修复后旧候选或未绑定候选的批准失效;非终态决定后续跑 |
|
|
30
|
+
| 合并决定退回修复 | `buildbeat decide --repo . --run <RUN> --action fix --reason <要修什么> --by <owner>`;仅合并决定点且有 fixer 时可用,再 `buildbeat run --config <config> --run <RUN>` 续跑;原因记为已接受 P1,重验、再审,review 预算照常累计 |
|
|
28
31
|
| 问题接受、驳回 | `buildbeat decide --repo . --work <ID> --action accept|dismiss --fingerprint <fp> --by <owner> --note <reason>` |
|
|
29
|
-
| 已手修并提交 | `buildbeat run --config <config> --run <RUN> --adopt <sha> --by <name>`;检查干净工作树与实际 HEAD 后从 verify
|
|
32
|
+
| 已手修并提交 | `buildbeat run --config <config> --run <RUN> --adopt <sha> --by <name>`;检查干净工作树与实际 HEAD 后从 verify 继续;合并决定点也可用,此时须为当前候选的新后代且改动不越界 |
|
|
30
33
|
| 配置或环境有问题 | `buildbeat check --config <config>`;显式 `--step <step>` 会在主检出执行 worker,只有用户任务需要才调用,结果不是正式证据 |
|
|
31
34
|
| 上线后回读、关窗 | 人完成上线后 `buildbeat release --config <config> [--note <text>]`;最近一次回读通过再 `buildbeat decide --repo . --work <ID> --action close --result <text> --by <owner>`。回读失败不能关窗 |
|
|
32
35
|
| 结束一轮 | `buildbeat stop --repo . --run <RUN> --reason <reason>`;活动驱动持锁时先处理进程,不能把 stop 当作进程杀手 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BuildBeat capability matrix / 能力矩阵
|
|
2
2
|
|
|
3
|
-
Current release: 4.1.
|
|
3
|
+
Current release: 4.2.1. One product path: Skill → local runtime →
|
|
4
4
|
project records. The Claude Code plugin distributes the Skill and references;
|
|
5
5
|
install the runtime separately. Files remain readable without the runtime, but
|
|
6
6
|
manual work has no automatic loop, isolation, budget or approval enforcement;
|
|
@@ -13,7 +13,7 @@ may be written by hand.
|
|
|
13
13
|
| Delivery loop | Fixed build/verify/review/fix flow | External CLI tools do the actual work |
|
|
14
14
|
| Recovery | Run/step ledger, resume, manual candidate adoption, stale locks | Active runtime is not transferred by Git clone |
|
|
15
15
|
| Trusted result | Real command evidence, current candidate, read-only review | Script tests do not prove real AI business capability |
|
|
16
|
-
| Decisions | Acceptance, exact approval, frozen checks, budget/convergence stops | No automatic merge, push or deployment |
|
|
16
|
+
| Decisions | Acceptance, exact approval bound to the candidate, frozen checks, budget/convergence stops, repairs at the merge decision (adopt a hand fix or send it back to the fixer) | No automatic merge, push or deployment |
|
|
17
17
|
| Efficiency | Verify cache, review range hints, parallel Work isolation | External test state must be isolated and stable |
|
|
18
18
|
| Feedback | Unified status/cost/decisions, also across a main repository and its code repositories (`--all-repos`), liveness, webhook/DingTalk | Notifications do not approve |
|
|
19
19
|
| UI evidence | `requireScreenshot`: screenshots from verify, bound to the candidate and required at merge | The project's verify renders and captures |
|
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@4.1
|
|
5
|
+
Release evidence at source package version `@haiyangbg/buildbeat@4.2.1`; latest independently verified BuildBeat npm distribution `@haiyangbg/buildbeat@4.2.0` (dist-tags `latest` and `next`; the owner moved `next` from `4.0.0` to `4.2.0` with npm 2FA on 2026-10-07), anchored by annotated tag `v4.2.0` at commit `cc52ad3`, workflow run [37597691057](https://github.com/HaiYangBG1/BuildBeat/actions/runs/37597691057), and archived in [`V4.2.0-RELEASE-EVIDENCE-2026-10-07.md`](releases/V4.2.0-RELEASE-EVIDENCE-2026-10-07.md). The 4.1.0 chain (`latest` on 2026-10-07 until 4.2.0 took over the same day) stays archived in [`V4.1.0-RELEASE-EVIDENCE-2026-10-07.md`](releases/V4.1.0-RELEASE-EVIDENCE-2026-10-07.md). The 4.0.0 chain (`latest` from 2026-10-05 until 4.1.0 took over on 2026-10-07; the owner moved `next` from `3.3.1` to `4.0.0` with npm 2FA on 2026-10-05) stays archived in [`V4.0.0-RELEASE-EVIDENCE-2026-10-05.md`](releases/V4.0.0-RELEASE-EVIDENCE-2026-10-05.md). The 3.3.1 chain (`latest` from 2026-10-02 until 4.0.0 took over on 2026-10-05; the owner moved `next` from `3.3.0` to `3.3.1` with npm 2FA on 2026-10-02) stays archived in [`V3.3.1-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.1-RELEASE-EVIDENCE-2026-10-02.md). The 3.3.0 chain (`latest` on 2026-10-02 until 3.3.1 took over the same day; the owner moved `next` from `3.2.1` to `3.3.0` with npm 2FA that day) stays archived in [`V3.3.0-RELEASE-EVIDENCE-2026-10-02.md`](releases/V3.3.0-RELEASE-EVIDENCE-2026-10-02.md). The 3.2.1 chain (`latest` from 2026-09-26 until 3.3.0 took over on 2026-10-02; the owner moved `next` from `3.0.1` to `3.2.1` with npm 2FA on 2026-09-26) stays archived in [`V3.2.1-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.2.1-RELEASE-EVIDENCE-2026-09-26.md). The 3.2.0 chain (`latest` on 2026-09-26 until 3.2.1 took over the same day) stays archived in [`V3.2.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.2.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.1.0 chain (`latest` on 2026-09-26 until 3.2.0 took over the same day) stays archived in [`V3.1.0-RELEASE-EVIDENCE-2026-09-26.md`](releases/V3.1.0-RELEASE-EVIDENCE-2026-09-26.md). The 3.0.1 chain (`latest` from 2026-09-09 until 3.1.0 took over on 2026-09-26) stays archived in [`V3.0.1-RELEASE-EVIDENCE-2026-09-09.md`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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`](releases/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/releases/`.
|
|
6
6
|
|
|
7
7
|
## Channels and branches
|
|
8
8
|
|
|
@@ -10,4 +10,6 @@ Review input includes adjudication anchors and an available lastReviewed increme
|
|
|
10
10
|
|
|
11
11
|
Use exit 75 for missing tools, unavailable backends or an unusable execution environment. Do not change business code to hide an environment failure. Do not print credentials. The runner reads candidate, exit status and evidence itself.
|
|
12
12
|
|
|
13
|
+
An invalid envelope is `invalid-output`, an environment failure, never a reason to change business code. A review step that exits 0 without writing its report to `$BUILDBEAT_OUTPUT` (printing it to stdout is not enough) is `invalid-output` too and stops for a person; a clean review hands in `{"findings": []}`.
|
|
14
|
+
|
|
13
15
|
`BUILDBEAT_INPUT.workArtifact` carries the selected repository-relative `ref` and accepted `digest`. Read that artifact; use work.md first and legacy intent/plan only when work.md is absent.
|
|
@@ -10,6 +10,6 @@ reviewer 输入包含历史裁决 anchor 和可用的 lastReviewed 增量范围
|
|
|
10
10
|
|
|
11
11
|
环境缺工具、后端不可用、沙箱禁止必要操作等用 exit 75;不要通过修改业务代码掩盖环境问题。日志不得主动输出凭据。实际候选、退出码和证据都由内核回读,自述不能替代事实。
|
|
12
12
|
|
|
13
|
-
无效信封记为 `invalid-output
|
|
13
|
+
无效信封记为 `invalid-output`,属于环境故障,不能据此要求修改业务代码。审查步骤退出 0 却没有把报告写进 `$BUILDBEAT_OUTPUT`(只打印在 stdout 不算)同样记为 `invalid-output` 并停人;没有问题也要交 `{"findings": []}`。
|
|
14
14
|
|
|
15
15
|
`BUILDBEAT_INPUT.workArtifact` carries the selected repository-relative `ref` and accepted `digest`. Read that artifact; use work.md first and legacy intent/plan only when work.md is absent.
|
|
@@ -6,6 +6,17 @@ New work has one work.md. accept binds its current digest but does not start exe
|
|
|
6
6
|
|
|
7
7
|
Status presents the transition, candidate, plan digest, evidence, reasons and next action. decide --action approve|reject must name the run and transition. Approval re-reads the candidate and frozen safeguards; changed subjects, dirty trees, missing evidence or unacceptable reviews cannot be stamped. Continue nonterminal decisions with run. Final approval means merge-ready only.
|
|
8
8
|
|
|
9
|
+
At the merge decision, `run --adopt`, `decide --action fix` and the approval command’s `--candidate` binding require runtime 4.2 or later.
|
|
10
|
+
|
|
11
|
+
When a problem is found at the merge decision (`enter-wait-merge`), repair it within the same Run and verify and review again:
|
|
12
|
+
|
|
13
|
+
- Manual repair: commit in the Run worktree, then execute `buildbeat run --config <config> --run <RUN> --adopt <sha> --by <name>`. The worktree must be clean, the SHA must be its actual HEAD and a new descendant of the current candidate, and changes must stay within `allowedPaths`. Resume at verify, review incrementally from the previous candidate, then wait for another merge decision.
|
|
14
|
+
- Return to the fixer: execute `buildbeat decide --repo <repo> --run <RUN> --action fix --reason <what to repair> --by <name>`, then continue with `buildbeat run --config <config> --run <RUN>`. This is available only at the merge decision with a configured fixer; otherwise commit a manual repair and adopt it. The reason becomes an accepted P1 in the Work findings account and is passed in full to the fixer.
|
|
15
|
+
|
|
16
|
+
After either repair path returns to the merge decision, read `buildbeat status --repo <repo> --run <RUN>` and copy its refreshed approval command: `buildbeat decide --repo <repo> --run <RUN> --action approve --transition enter-wait-merge --candidate <full SHA from the card> --by <name>`. The candidate is filled in by status, inbox and notifications. Approval with the old candidate, or without a candidate after repair, fails with `approval stale`; read the new card before deciding.
|
|
17
|
+
|
|
18
|
+
The repair decision answers the old request and preserves historical evidence; the merge check uses only evidence for the current candidate. Returning to fix does not spend a review round, grant budget or refund it. Subsequent reviews count normally. SUCCEEDED Runs cannot be reopened; active 3.x Runs still require their original runtime.
|
|
19
|
+
|
|
9
20
|
Use decide --action accept|dismiss --work ... --fingerprint ... for findings. Exact-fingerprint dismissals persist; similarity detects nonconverging reviews and never substitutes for adjudication.
|
|
10
21
|
|
|
11
22
|
Normal repairs continue automatically. Repeated issues, increased blockers, infrastructure failures and exhausted budgets create exception decisions. One budget grant covers its repair/reverification/review plan and can be replayed. The driving session cannot approve on behalf of the user without authorization.
|
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
status 的决定卡包含 transition、候选、计划摘要、证据、原因和下一步。decide --action approve|reject 必须指向该 Run 与转换。批准前重新回读候选与冻结校验;变化、脏树、缺证据或不合格审查不能盖章。非终态批准后用 run 续跑,最终批准只表示具备合并条件。
|
|
8
8
|
|
|
9
|
+
合并决定点的 `run --adopt`、`decide --action fix` 与批准命令的 `--candidate` 候选绑定要求运行时 4.2 或更高版本。
|
|
10
|
+
|
|
11
|
+
合并决定点(`enter-wait-merge`)发现问题时,可以在本 Run 中修复并重新验证、审查:
|
|
12
|
+
|
|
13
|
+
- 手工修复:在 Run 工作树提交后执行 `buildbeat run --config <config> --run <RUN> --adopt <sha> --by <name>`。工作树必须干净,SHA 必须是实际 HEAD,并且是当前候选的新后代;改动仍受 `allowedPaths` 约束。从 verify 继续,review 使用上一候选的增量范围,最后再次等待合并决定。
|
|
14
|
+
- 退回 fixer:执行 `buildbeat decide --repo <repo> --run <RUN> --action fix --reason <要修什么> --by <name>`,再用 `buildbeat run --config <config> --run <RUN>` 续跑。仅合并决定点可用,且配置必须有 fixer;否则先手修再接纳。原因记为本 Work 已接受的 P1 问题,fixer 收到完整原因。
|
|
15
|
+
|
|
16
|
+
任一修复路径回到合并决定后,先读 `buildbeat status --repo <repo> --run <RUN>`,复制刷新后的批准命令:`buildbeat decide --repo <repo> --run <RUN> --action approve --transition enter-wait-merge --candidate <卡片中的完整 SHA> --by <name>`。状态卡、待批列表和通知会填好候选;旧候选命令或修复后未携带候选的命令会报 `approval stale`,需要重新读取卡片再决定。
|
|
17
|
+
|
|
18
|
+
修复决定回答旧请求,保留历史证据;合并检查只使用当前候选的证据。退回本身不计 review、不扩额也不退款,后续 review 照常累计。已批准的 SUCCEEDED Run 不重新打开;3.x 活动 Run 仍由原运行时处理。
|
|
19
|
+
|
|
9
20
|
审查问题通过 decide --action accept|dismiss --work ... --fingerprint ... 裁决。精确指纹的驳回持续生效;换说法的相似性只用于停止不收敛循环,不代替人的裁决。
|
|
10
21
|
|
|
11
22
|
正常修复自动继续;重复问题、阻断数增加、环境故障或预算耗尽才形成例外决定。预算批准一次放行对应修复、重验和再审;扩额记录可以重放,驾驶会话不能自行冒充用户批准。
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](10-recovery.md)
|
|
4
4
|
|
|
5
|
+
At the merge decision, `run --adopt`, `decide --action fix` and the approval command’s `--candidate` binding require runtime 4.2 or later.
|
|
6
|
+
|
|
5
7
|
- run resumes the unique nonterminal run in its family; choose --run when ambiguous. Archived work needs explicit --new to start another attempt.
|
|
6
8
|
- An interrupted step reruns itself from the ledger. Handle a dirty worktree first. After committing a manual fix, --adopt checks the actual HEAD and continues at verify.
|
|
9
|
+
- The merge decision also accepts a manual repair: the new HEAD must descend from the candidate and stay in scope. Alternatively use `decide --action fix --repo <repo> --run <RUN> --reason <what to repair> --by <name>` with a configured fixer, then `run --config <config> --run <RUN>`. Both paths verify and review again before another merge decision, preserving review budgets and historical evidence.
|
|
10
|
+
- After repair, read the refreshed status card and copy its approval command with `--candidate <full SHA>`; old or unbound approvals fail with `approval stale`.
|
|
7
11
|
- Reclaim a lock only when its owner is on this host and provably dead. Keep live, foreign-host and unidentified locks. stop is not a process-kill command.
|
|
8
12
|
- Timeouts, crashes, malformed envelopes and exit 75 are infra: no fixer and no failure-budget charge. Decide and continue once the environment recovers.
|
|
9
13
|
- A corrupt ledger exposes only its valid prefix and refuses appends. history --verify replays and validates without silently repairing it. Preserve the scene and candidate.
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[English](10-recovery.en.md)
|
|
4
4
|
|
|
5
|
+
合并决定点的 `run --adopt`、`decide --action fix` 与批准命令的 `--candidate` 候选绑定要求运行时 4.2 或更高版本。
|
|
6
|
+
|
|
5
7
|
- run 自动续接已有家族的唯一未终态 Run;多个候选时必须 --run 指定。历史工作需明确 --new 才会重新启动。
|
|
6
8
|
- 中断步按台账重跑自身;脏工作树先处理。手修并提交后用 --adopt 指定实际 HEAD,从 verify 继续。
|
|
9
|
+
- 合并决定点也可接纳手修:要求新 HEAD 是当前候选后代且改动不越界;也可 `decide --action fix --repo <repo> --run <RUN> --reason <要修什么> --by <name>` 退回已配置的 fixer,再 `run --config <config> --run <RUN>`。两者都会重验、再审并重新等待合并决定,review 预算与历史证据保留。
|
|
10
|
+
- 修复后重新读取状态卡,复制带 `--candidate <完整 SHA>` 的批准命令;旧候选或未绑定候选的批准会报 `approval stale`。
|
|
7
11
|
- 只有同机且持有者进程已不存在的锁自动回收。活动、异机、缺持有者信息的锁保留;stop 不是杀进程命令。
|
|
8
12
|
- 超时、崩溃、无效信封、exit 75 属于 infra,不派 fixer、不扣失败预算。环境恢复后按待决策对象处理并续跑。
|
|
9
13
|
- 台账损坏只读取合法前缀,拒绝追加;history --verify 校验回放,不会静默修复。保留现场与候选后决定恢复方式。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haiyangbg/buildbeat",
|
|
3
|
-
"version": "4.1
|
|
3
|
+
"version": "4.2.1",
|
|
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": {
|
package/src/v2/cli/run.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
approveRun,
|
|
33
33
|
listInbox,
|
|
34
34
|
rejectRun,
|
|
35
|
+
requestFix,
|
|
35
36
|
} from "../runtime/decisions.js";
|
|
36
37
|
import { checkRequires } from "../runtime/env-contract.js";
|
|
37
38
|
import {
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
renderOverview,
|
|
51
52
|
runsFor,
|
|
52
53
|
} from "../runtime/overview.js";
|
|
53
|
-
import { pathLabel, shellArg } from "../runtime/overview-repos.js";
|
|
54
|
+
import { configTargets, workConfigs, pathLabel, shellArg } from "../runtime/overview-repos.js";
|
|
54
55
|
import { closeWork, runReadback } from "../runtime/release.js";
|
|
55
56
|
import {
|
|
56
57
|
DEFAULT_STALL_AFTER_MS,
|
|
@@ -97,7 +98,8 @@ Usage:
|
|
|
97
98
|
buildbeat accept --repo <path> --work <WORK-ID> [--by <name>]
|
|
98
99
|
buildbeat run --config <run-config.yaml> [--run <RUN-ID>] [--new] [--adopt <sha> --by <name>]
|
|
99
100
|
buildbeat status --repo <path> [--work <WORK-ID> | --run <RUN-ID>] [--all-repos] [--json]
|
|
100
|
-
buildbeat decide --repo <path> --run <RUN-ID> --action approve|reject --transition <t> [--by <name>] [--reason <text>]
|
|
101
|
+
buildbeat decide --repo <path> --run <RUN-ID> --action approve|reject --transition <t> [--candidate <sha>] [--by <name>] [--reason <text>]
|
|
102
|
+
buildbeat decide --repo <path> --run <RUN-ID> --action fix --reason <text> [--by <name>]
|
|
101
103
|
buildbeat decide --repo <path> --work <WORK-ID> --action accept|dismiss --fingerprint <fp> [--by <name>] [--note <text>]
|
|
102
104
|
buildbeat release --config <run-config.yaml> [--ref <ref>] [--note <text>]
|
|
103
105
|
buildbeat decide --repo <path> --work <WORK-ID> --action close --result <text> [--by <name>]
|
|
@@ -179,8 +181,8 @@ function repoLabelFor(repoRoot, typed) {
|
|
|
179
181
|
return rel;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
function printNextReply(repoLabel, state) {
|
|
183
|
-
const lines = nextReply({ repoLabel, state });
|
|
184
|
+
function printNextReply(repoLabel, state, repoRoot) {
|
|
185
|
+
const lines = nextReply({ repoLabel, state, repoRoot });
|
|
184
186
|
if (lines.length === 0) {
|
|
185
187
|
return;
|
|
186
188
|
}
|
|
@@ -289,7 +291,7 @@ function printState(state, ledger, view = {}) {
|
|
|
289
291
|
console.log(` reason: ${reason}`);
|
|
290
292
|
}
|
|
291
293
|
if (view.repoLabel) {
|
|
292
|
-
printNextReply(view.repoLabel, state);
|
|
294
|
+
printNextReply(view.repoLabel, state, view.repoRoot);
|
|
293
295
|
}
|
|
294
296
|
}
|
|
295
297
|
if (state.terminal) {
|
|
@@ -590,7 +592,7 @@ async function notifyForState(repoRoot, repoLabel, state) {
|
|
|
590
592
|
}
|
|
591
593
|
const results = await dispatchNotification(
|
|
592
594
|
config,
|
|
593
|
-
buildNotification(kind, { repoLabel, state }),
|
|
595
|
+
buildNotification(kind, { repoLabel, state, repoRoot }),
|
|
594
596
|
{ repoRoot },
|
|
595
597
|
);
|
|
596
598
|
for (const row of results) {
|
|
@@ -915,6 +917,7 @@ async function commandResume(
|
|
|
915
917
|
sha: flags.adopt,
|
|
916
918
|
by: flags.by ?? "human",
|
|
917
919
|
resumeAt,
|
|
920
|
+
allowedPaths: options.allowedPaths,
|
|
918
921
|
});
|
|
919
922
|
console.log(
|
|
920
923
|
`adopted ${adopted.adopted} as candidate (${adopted.decisionRef}, answers ${adopted.transition}); resuming at ${adopted.resumeAt}`,
|
|
@@ -1009,6 +1012,7 @@ function commandInbox(flags, rows = null) {
|
|
|
1009
1012
|
for (const line of nextReply({
|
|
1010
1013
|
repoLabel: row.repo ? shellArg(pathLabel(process.cwd(), rowRepo)) : repoLabelFor(repoRoot, flags.repo),
|
|
1011
1014
|
state: ledger.state,
|
|
1015
|
+
repoRoot: rowRepo,
|
|
1012
1016
|
})) {
|
|
1013
1017
|
console.log(` next: ${row.config ? line.replaceAll("--config <run-config.yaml>", `--config ${shellArg(pathLabel(process.cwd(), row.config))}`) : line}`);
|
|
1014
1018
|
}
|
|
@@ -1033,6 +1037,7 @@ function commandApprove(flags) {
|
|
|
1033
1037
|
const result = approveRun(resolve(flags.repo), flags.run, {
|
|
1034
1038
|
by: flags.by ?? "human",
|
|
1035
1039
|
transition: flags.transition,
|
|
1040
|
+
candidate: flags.candidate,
|
|
1036
1041
|
policies,
|
|
1037
1042
|
});
|
|
1038
1043
|
if (!result.approved) {
|
|
@@ -1628,6 +1633,28 @@ function commandDecide(flags) {
|
|
|
1628
1633
|
}
|
|
1629
1634
|
if (flags.action === "approve") return commandApprove(flags);
|
|
1630
1635
|
if (flags.action === "reject") return commandReject(flags);
|
|
1636
|
+
if (flags.action === "fix") {
|
|
1637
|
+
if (!flags.repo || !flags.run) throw new Error("fix requires --repo and --run");
|
|
1638
|
+
const repoRoot = resolve(flags.repo);
|
|
1639
|
+
const prior = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
1640
|
+
let hasFixer;
|
|
1641
|
+
// Older 4.x ledgers did not freeze repair capabilities. Discover a
|
|
1642
|
+
// single matching configuration; never guess between several configs.
|
|
1643
|
+
if (prior.state.run?.deliveryChecks && !prior.state.run.repair) {
|
|
1644
|
+
const configs = configTargets(workConfigs(repoRoot, prior.state.run.work), []);
|
|
1645
|
+
const candidates = configs.filter((item) => item.repo === realpathSync(repoRoot)).map((item) =>
|
|
1646
|
+
loadRunConfig({ config: item.path }, "resume")).filter((options) =>
|
|
1647
|
+
inRunFamily(options.runId, flags.run));
|
|
1648
|
+
if (candidates.length !== 1) throw new Error("cannot identify the run configuration; use manual repair and run --adopt");
|
|
1649
|
+
assertRunConfiguration(prior.state, candidates[0]);
|
|
1650
|
+
hasFixer = Boolean(candidates[0].adapters.fixer);
|
|
1651
|
+
}
|
|
1652
|
+
const result = requestFix(repoRoot, flags.run, {
|
|
1653
|
+
by: flags.by ?? "human", reason: flags.reason, hasFixer,
|
|
1654
|
+
});
|
|
1655
|
+
console.log(`repair requested as ${result.decisionRef}; continue with: buildbeat run --config <run-config.yaml> --run ${flags.run}`);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1631
1658
|
if (["accept", "dismiss"].includes(flags.action) && flags.fingerprint) {
|
|
1632
1659
|
if (!flags.repo || !flags.work)
|
|
1633
1660
|
throw new Error("finding decisions require --repo and --work");
|
|
@@ -1641,7 +1668,7 @@ function commandDecide(flags) {
|
|
|
1641
1668
|
return;
|
|
1642
1669
|
}
|
|
1643
1670
|
throw new Error(
|
|
1644
|
-
"decide requires --action approve|reject, accept|dismiss with --work and --fingerprint, or close with --work and --result",
|
|
1671
|
+
"decide requires --action approve|reject|fix, accept|dismiss with --work and --fingerprint, or close with --work and --result",
|
|
1645
1672
|
);
|
|
1646
1673
|
}
|
|
1647
1674
|
|
package/src/v2/domain/model.js
CHANGED
|
@@ -48,7 +48,7 @@ export const EVIDENCE_STATUSES = ["passed", "failed", "unverified"];
|
|
|
48
48
|
|
|
49
49
|
export const EVIDENCE_GRADES = ["L0", "L1", "L2", "L3", "L4"];
|
|
50
50
|
|
|
51
|
-
export const DECISION_VALUES = ["approved", "rejected"];
|
|
51
|
+
export const DECISION_VALUES = ["approved", "rejected", "fix"];
|
|
52
52
|
|
|
53
53
|
// observe v0 per docs/v2/RFC-0003-workflow-policy.md §8 (frozen shape):
|
|
54
54
|
// bands are exactly three fixed levels; triage actions are a closed set.
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -71,6 +71,7 @@ export function applyEvent(state, event) {
|
|
|
71
71
|
planDigest: data.planDigest ?? "UNVERIFIED",
|
|
72
72
|
intentDigest: data.intentDigest ?? "UNVERIFIED",
|
|
73
73
|
...(data.deliveryChecks ? { deliveryChecks: data.deliveryChecks } : {}),
|
|
74
|
+
...(data.repair ? { repair: data.repair } : {}),
|
|
74
75
|
};
|
|
75
76
|
break;
|
|
76
77
|
}
|
|
@@ -221,7 +222,7 @@ export function applyEvent(state, event) {
|
|
|
221
222
|
subject: data.subject,
|
|
222
223
|
decisionRef: data.decisionRef,
|
|
223
224
|
});
|
|
224
|
-
if (data.decision === "approved") {
|
|
225
|
+
if (data.decision === "approved" || data.decision === "fix") {
|
|
225
226
|
next.approvals.push({
|
|
226
227
|
decisionRef: data.decisionRef,
|
|
227
228
|
transition: data.transition,
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
readdirSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { adjudicateFinding, fingerprintFinding, recordReviewFindings } from "./findings.js";
|
|
17
19
|
|
|
18
20
|
import {
|
|
19
21
|
deliveryChecks,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
24
26
|
import {
|
|
25
27
|
acquireLock,
|
|
28
|
+
listChangedPaths,
|
|
26
29
|
readback,
|
|
27
30
|
releaseLock,
|
|
28
31
|
} from "../workspace/workspace-manager.js";
|
|
@@ -105,7 +108,7 @@ function recordDecisionFile(repoRoot, work, line) {
|
|
|
105
108
|
export function approveRun(
|
|
106
109
|
repoRoot,
|
|
107
110
|
runId,
|
|
108
|
-
{ by = "human", transition, ts, policies } = {},
|
|
111
|
+
{ by = "human", transition, ts, policies, candidate } = {},
|
|
109
112
|
) {
|
|
110
113
|
// Read, check and write under the run lock: a ledger read before the
|
|
111
114
|
// lock may be stale by the time it is written to.
|
|
@@ -113,6 +116,12 @@ export function approveRun(
|
|
|
113
116
|
try {
|
|
114
117
|
const ledger = openWaiting(repoRoot, runId);
|
|
115
118
|
const pending = ledger.state.pendingHuman;
|
|
119
|
+
const repaired = ledger.events.some((event) => event.type === "DECISION_RECORDED" &&
|
|
120
|
+
event.data.transition === "enter-wait-merge" && (event.data.adopted || event.data.decision === "fix"));
|
|
121
|
+
if ((candidate && candidate !== pending.subject.candidate) ||
|
|
122
|
+
(pending.kind === "final-decision" && repaired && !candidate)) {
|
|
123
|
+
throw new DecisionError("approval stale: read the current decision card and bind approval to its candidate");
|
|
124
|
+
}
|
|
116
125
|
if (!transition) {
|
|
117
126
|
throw new DecisionError(
|
|
118
127
|
`an approval must name its transition explicitly (pending: ${pending.transition})`,
|
|
@@ -291,7 +300,7 @@ export function approveRun(
|
|
|
291
300
|
export function adoptCandidate(
|
|
292
301
|
repoRoot,
|
|
293
302
|
runId,
|
|
294
|
-
{ sha, by = "human", resumeAt, ts } = {},
|
|
303
|
+
{ sha, by = "human", resumeAt, ts, allowedPaths } = {},
|
|
295
304
|
) {
|
|
296
305
|
acquireLock(repoRoot, runId);
|
|
297
306
|
try {
|
|
@@ -307,10 +316,12 @@ export function adoptCandidate(
|
|
|
307
316
|
}
|
|
308
317
|
const ledger = openWaiting(repoRoot, runId);
|
|
309
318
|
const pending = ledger.state.pendingHuman;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
const final = pending.kind === "final-decision" && pending.transition === "enter-wait-merge";
|
|
320
|
+
if (pending.kind === "final-decision" && (!final || !ledger.state.run.deliveryChecks)) {
|
|
321
|
+
throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
|
|
322
|
+
}
|
|
323
|
+
if (final && resumeAt !== "verify") {
|
|
324
|
+
throw new DecisionError("a final-decision repair must resume at verify");
|
|
314
325
|
}
|
|
315
326
|
const bound = ledger.state.workspaces[runId];
|
|
316
327
|
const worktreePath = bound
|
|
@@ -332,6 +343,19 @@ export function adoptCandidate(
|
|
|
332
343
|
`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`,
|
|
333
344
|
);
|
|
334
345
|
}
|
|
346
|
+
if (final) {
|
|
347
|
+
const candidate = bound.candidate ?? bound.base;
|
|
348
|
+
const ancestor = spawnSync("git", ["-C", worktreePath, "merge-base", "--is-ancestor", candidate, tree.head]);
|
|
349
|
+
if (tree.head === candidate || ancestor.status !== 0) {
|
|
350
|
+
throw new DecisionError("adopt requires a new descendant of the current candidate; history rewrites are refused");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const scope = ledger.state.run.repair?.allowedPaths ?? allowedPaths;
|
|
354
|
+
if (scope) {
|
|
355
|
+
const violations = listChangedPaths(worktreePath, bound.base).filter((path) =>
|
|
356
|
+
!scope.some((prefix) => path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`)));
|
|
357
|
+
if (violations.length) throw new DecisionError(`out-of-scope changes: ${violations.join(", ")}`);
|
|
358
|
+
}
|
|
335
359
|
const when = ts ?? new Date().toISOString();
|
|
336
360
|
const lastEvidence =
|
|
337
361
|
ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
@@ -390,6 +414,51 @@ export function adoptCandidate(
|
|
|
390
414
|
}
|
|
391
415
|
}
|
|
392
416
|
|
|
417
|
+
// Queue a human-requested repair without fabricating a review or a budget
|
|
418
|
+
// grant. The ordinary resume loop consumes this decision at the fix step.
|
|
419
|
+
export function requestFix(repoRoot, runId, { by = "human", reason, ts, hasFixer } = {}) {
|
|
420
|
+
acquireLock(repoRoot, runId);
|
|
421
|
+
try {
|
|
422
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
423
|
+
const pending = ledger.state.pendingHuman;
|
|
424
|
+
if (!ledger.state.run.deliveryChecks)
|
|
425
|
+
throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
|
|
426
|
+
if (pending.kind !== "final-decision" || pending.transition !== "enter-wait-merge")
|
|
427
|
+
throw new DecisionError("fix is only available at the merge decision");
|
|
428
|
+
if (!(ledger.state.run.repair?.hasFixer ?? hasFixer))
|
|
429
|
+
throw new DecisionError("no fixer configured; commit a manual repair and use run --adopt <sha> --by <name>");
|
|
430
|
+
if (typeof reason !== "string" || !reason.trim())
|
|
431
|
+
throw new DecisionError("fix requires a non-empty reason describing what to repair");
|
|
432
|
+
const bound = ledger.state.workspaces[runId];
|
|
433
|
+
const worktreePath = bound && resolveRepoRef(repoRoot, bound.worktreePath);
|
|
434
|
+
if (!worktreePath || !existsSync(worktreePath))
|
|
435
|
+
throw new DecisionError(`worktree missing for ${runId}`);
|
|
436
|
+
const tree = readback(worktreePath);
|
|
437
|
+
if (tree.dirty || tree.head !== (bound.candidate ?? bound.base) || tree.head !== pending.subject.candidate)
|
|
438
|
+
throw new DecisionError("worktree changed since the request; commit the manual repair and use run --adopt");
|
|
439
|
+
const when = ts ?? new Date().toISOString();
|
|
440
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
441
|
+
const finding = { severity: "P1", summary: reason.trim() };
|
|
442
|
+
recordReviewFindings(repoRoot, ledger.state.run.work, {
|
|
443
|
+
run: runId, step: "enter-wait-merge", attempt: ledger.state.decisions.length + 1,
|
|
444
|
+
findings: [finding], ts: when,
|
|
445
|
+
});
|
|
446
|
+
adjudicateFinding(repoRoot, ledger.state.run.work, {
|
|
447
|
+
fingerprint: fingerprintFinding(finding), action: "accept", by,
|
|
448
|
+
note: decisionRef, ts: when,
|
|
449
|
+
});
|
|
450
|
+
const data = {
|
|
451
|
+
decision: "fix", transition: pending.transition, subject: pending.subject,
|
|
452
|
+
decisionRef, reason: finding.summary, resumeAt: "fix",
|
|
453
|
+
};
|
|
454
|
+
ledger.append({ type: "DECISION_RECORDED", actor: { kind: "human", id: by }, ts: when, data });
|
|
455
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, { ts: when, run: runId, ...data, by });
|
|
456
|
+
return { decisionRef, state: ledger.state };
|
|
457
|
+
} finally {
|
|
458
|
+
releaseLock(repoRoot, runId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
393
462
|
// Accepts a work artifact (plan, intent, spec) by binding a decision to the
|
|
394
463
|
// file's current digest in the Git plane. If the file changes afterwards,
|
|
395
464
|
// artifact.accepted evaluates false again — acceptance cannot go stale
|
package/src/v2/runtime/notify.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import { payloadFor } from "../adapters/notification-payload.js";
|
|
12
12
|
import { currentScreenshots } from "../policy/policy.js";
|
|
13
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
|
|
16
|
+
import { configTargets, workConfigs } from "./overview-repos.js";
|
|
16
17
|
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
17
18
|
|
|
18
19
|
export const NOTIFY_CONFIG = join(".buildbeat", "notify.yaml");
|
|
@@ -89,7 +90,22 @@ export function candidateScreenshots(state, candidate) {
|
|
|
89
90
|
|
|
90
91
|
// The exact commands a human can copy to answer a pending request. Shared by
|
|
91
92
|
// status, inbox and notifications so "what do I say now" has one answer.
|
|
92
|
-
|
|
93
|
+
function configuredFixer(repoRoot, state) {
|
|
94
|
+
if (state.run.repair) return state.run.repair.hasFixer;
|
|
95
|
+
if (!repoRoot) return false;
|
|
96
|
+
// Existing 4.x runs have no repair snapshot. Hints are conservative when
|
|
97
|
+
// their configuration is missing, ambiguous or unreadable.
|
|
98
|
+
try {
|
|
99
|
+
const configs = configTargets(workConfigs(repoRoot, state.run.work), [])
|
|
100
|
+
.filter((item) => item.repo === realpathSync(repoRoot))
|
|
101
|
+
.map((item) => parseYamlSubset(readFileSync(item.path, "utf8")))
|
|
102
|
+
.filter((doc) => doc.run === state.run.id || (typeof doc.run === "string" &&
|
|
103
|
+
state.run.id.startsWith(`${doc.run}-`) && /^\d+$/.test(state.run.id.slice(doc.run.length + 1))));
|
|
104
|
+
return configs.length === 1 && Boolean(configs[0].workers?.fixer?.command);
|
|
105
|
+
} catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nextReply({ repoLabel, state, repoRoot }) {
|
|
93
109
|
const pending = state.pendingHuman;
|
|
94
110
|
if (!pending || !state.run) {
|
|
95
111
|
return [];
|
|
@@ -120,14 +136,21 @@ export function nextReply({ repoLabel, state }) {
|
|
|
120
136
|
);
|
|
121
137
|
}
|
|
122
138
|
lines.push(
|
|
123
|
-
`buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition}
|
|
139
|
+
`buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition}` +
|
|
140
|
+
(pending.kind === "final-decision" ? ` --candidate ${pending.subject.candidate}` : "") + " --by <you>" +
|
|
124
141
|
(pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: run --config <run-config.yaml>"),
|
|
125
142
|
);
|
|
126
143
|
lines.push(`buildbeat decide --action reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
|
|
144
|
+
if (pending.kind === "final-decision") {
|
|
145
|
+
lines.push(`buildbeat run --config <run-config.yaml> --run ${runId} --adopt <sha> --by <you> # commit a manual repair in the run worktree; verify and review again`);
|
|
146
|
+
if (configuredFixer(repoRoot, state)) {
|
|
147
|
+
lines.push(`buildbeat decide --repo ${repoLabel} --run ${runId} --action fix --reason <what to repair> --by <you> # then: run --config <run-config.yaml> --run ${runId}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
127
150
|
return lines;
|
|
128
151
|
}
|
|
129
152
|
|
|
130
|
-
export function buildNotification(kind, { repoLabel, state, detail = {} }) {
|
|
153
|
+
export function buildNotification(kind, { repoLabel, state, repoRoot, detail = {} }) {
|
|
131
154
|
const run = state.run;
|
|
132
155
|
const base = {
|
|
133
156
|
kind,
|
|
@@ -149,7 +172,7 @@ export function buildNotification(kind, { repoLabel, state, detail = {} }) {
|
|
|
149
172
|
reasons: pending?.reasons ?? [],
|
|
150
173
|
candidate: pending?.subject?.candidate ?? null,
|
|
151
174
|
...(screenshots.length > 0 ? { screenshots } : {}),
|
|
152
|
-
nextReply: nextReply({ repoLabel, state }),
|
|
175
|
+
nextReply: nextReply({ repoLabel, state, repoRoot }),
|
|
153
176
|
};
|
|
154
177
|
}
|
|
155
178
|
if (kind === "RUN_TERMINAL") {
|
|
@@ -744,6 +744,21 @@ function beginStep(context, step, stepDef, attempt) {
|
|
|
744
744
|
}));
|
|
745
745
|
}
|
|
746
746
|
}
|
|
747
|
+
// A human's final-decision repair is separate from reviewer evidence: it
|
|
748
|
+
// must not spend a review round or pretend that a reviewer reported it.
|
|
749
|
+
if (step === "fix") {
|
|
750
|
+
const decision = [...ledger.events].reverse().find((event) =>
|
|
751
|
+
event.type === "DECISION_RECORDED" && event.data.decision === "fix");
|
|
752
|
+
const priorFix = [...ledger.events].reverse().find((event) =>
|
|
753
|
+
event.type === "STEP_FINISHED" && event.data.step === "fix" && event.data.status === "succeeded");
|
|
754
|
+
if (decision && (!priorFix || decision.seq > priorFix.seq)) {
|
|
755
|
+
const finding = { severity: "P1", summary: decision.data.reason };
|
|
756
|
+
input.findings = [...(input.findings ?? []).filter((item) =>
|
|
757
|
+
item.fingerprint !== fingerprintFinding(finding)), {
|
|
758
|
+
...finding, fingerprint: fingerprintFinding(finding), adjudication: "accept",
|
|
759
|
+
}];
|
|
760
|
+
}
|
|
761
|
+
}
|
|
747
762
|
// Envelope (C6): the worker's prompt, materialised into the run
|
|
748
763
|
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
749
764
|
const prompt = materialisePrompt({
|
|
@@ -1058,6 +1073,13 @@ function recordStepResult(
|
|
|
1058
1073
|
envelopeRaw = readFileSync(outputPath, "utf8");
|
|
1059
1074
|
}
|
|
1060
1075
|
const { envelope, error: envelopeError } = parseEnvelope(envelopeRaw);
|
|
1076
|
+
// A review that exits cleanly without a report reviewed nothing the
|
|
1077
|
+
// kernel can check: no evidence, no findings. Real incident: a reviewer
|
|
1078
|
+
// printed 3 P1, 6 P2 and 4 P3 findings to stdout only; the step counted
|
|
1079
|
+
// as passed and the gap surfaced only at the merge approval.
|
|
1080
|
+
const reportMissing =
|
|
1081
|
+
isReviewStep(step, stepDef) &&
|
|
1082
|
+
(envelopeRaw === undefined || envelopeRaw === null);
|
|
1061
1083
|
|
|
1062
1084
|
let stepStatus;
|
|
1063
1085
|
if (exec.spawnError) {
|
|
@@ -1072,11 +1094,20 @@ function recordStepResult(
|
|
|
1072
1094
|
// The command passed but a requirement the runner checks did not
|
|
1073
1095
|
// (no screenshots): a candidate failure, routed like any other.
|
|
1074
1096
|
stepStatus = "failed";
|
|
1075
|
-
} else if (envelopeError) {
|
|
1097
|
+
} else if (envelopeError || reportMissing) {
|
|
1076
1098
|
stepStatus = "invalid-output";
|
|
1077
1099
|
} else {
|
|
1078
1100
|
stepStatus = "succeeded";
|
|
1079
1101
|
}
|
|
1102
|
+
let outputProblem = null;
|
|
1103
|
+
if (stepStatus === "invalid-output" && !envelopeError) {
|
|
1104
|
+
const stdoutBytes = Buffer.byteLength(String(exec.stdout ?? "").trim());
|
|
1105
|
+
outputProblem =
|
|
1106
|
+
"the reviewer exited 0 without writing a report to $BUILDBEAT_OUTPUT (stdout is not read)" +
|
|
1107
|
+
(stdoutBytes > 0
|
|
1108
|
+
? `; stdout has ${stdoutBytes} bytes; write the JSON report to $BUILDBEAT_OUTPUT`
|
|
1109
|
+
: "");
|
|
1110
|
+
}
|
|
1080
1111
|
// Infrastructure failure vs candidate failure. A timeout, a crash,
|
|
1081
1112
|
// garbage output or the worker's own "environment unavailable" signal
|
|
1082
1113
|
// (exit 75, EX_TEMPFAIL) says nothing about the candidate: no failure
|
|
@@ -1101,6 +1132,7 @@ function recordStepResult(
|
|
|
1101
1132
|
status: stepStatus,
|
|
1102
1133
|
exitCode: exec.exitCode,
|
|
1103
1134
|
...(exec.requirementFailure ? { reason: exec.requirementFailure } : {}),
|
|
1135
|
+
...(outputProblem ? { reason: outputProblem } : {}),
|
|
1104
1136
|
...(exec.screenshotsRejected
|
|
1105
1137
|
? { screenshotsRejected: exec.screenshotsRejected }
|
|
1106
1138
|
: {}),
|
|
@@ -1123,7 +1155,7 @@ function recordStepResult(
|
|
|
1123
1155
|
stepStatus === "failed"
|
|
1124
1156
|
? "exit 75 (worker reports its environment unavailable)"
|
|
1125
1157
|
: stepStatus === "invalid-output"
|
|
1126
|
-
? "output is not a worker envelope"
|
|
1158
|
+
? (outputProblem ?? "output is not a worker envelope")
|
|
1127
1159
|
: stepStatus;
|
|
1128
1160
|
context.waitHuman(
|
|
1129
1161
|
`resume-${step}`,
|
|
@@ -1506,6 +1538,9 @@ export function startRun(options) {
|
|
|
1506
1538
|
run: runId,
|
|
1507
1539
|
work: workId,
|
|
1508
1540
|
data: {
|
|
1541
|
+
...(options.deliveryChecks ? {
|
|
1542
|
+
repair: { hasFixer: Boolean(options.adapters?.fixer), allowedPaths: options.allowedPaths ?? null },
|
|
1543
|
+
} : {}),
|
|
1509
1544
|
workflowRef: workflow.name,
|
|
1510
1545
|
workflowDigest,
|
|
1511
1546
|
base: workspace.base,
|
|
@@ -1574,6 +1609,10 @@ export function assertRunConfiguration(state, options) {
|
|
|
1574
1609
|
if (!state.run) throw new OrchestratorError("run has no creation record");
|
|
1575
1610
|
if (options.workId && options.workId !== state.run.work)
|
|
1576
1611
|
throw new OrchestratorError("run belongs to a different work");
|
|
1612
|
+
if (state.run.repair && (
|
|
1613
|
+
canonicalJson(state.run.repair.allowedPaths) !== canonicalJson(options.allowedPaths ?? null) ||
|
|
1614
|
+
state.run.repair.hasFixer !== Boolean(options.adapters?.fixer)
|
|
1615
|
+
)) throw new OrchestratorError("repair configuration changed since the run was created; refusing to resume");
|
|
1577
1616
|
const { workflowDigest } = options;
|
|
1578
1617
|
if (
|
|
1579
1618
|
state.run.deliveryChecks &&
|
|
@@ -324,7 +324,7 @@ export function computeOverview(
|
|
|
324
324
|
? "MERGE_DECISION"
|
|
325
325
|
: "WAITING_HUMAN";
|
|
326
326
|
const replies = latest.state
|
|
327
|
-
? nextReply({ repoLabel, state: latest.state })
|
|
327
|
+
? nextReply({ repoLabel, state: latest.state, repoRoot })
|
|
328
328
|
: [];
|
|
329
329
|
next = replies[0] ?? `buildbeat status --repo ${repoLabel} --work ${workId}`;
|
|
330
330
|
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
@@ -12,12 +12,29 @@ import { join } from "node:path";
|
|
|
12
12
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
13
13
|
import { readFindingsAccount } from "./findings.js";
|
|
14
14
|
|
|
15
|
+
// How each human wait ended. Counting waits alone hid where the time went:
|
|
16
|
+
// one work showed 32 minutes of decided waits while 11 hours had passed on
|
|
17
|
+
// a request nobody answered before a new run superseded it.
|
|
18
|
+
export const WAIT_KINDS = ["decided", "superseded", "stopped", "open"];
|
|
19
|
+
|
|
20
|
+
function emptyWaits() {
|
|
21
|
+
return Object.fromEntries(WAIT_KINDS.map((kind) => [kind, { count: 0, ms: 0 }]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addWait(bucket, ms) {
|
|
25
|
+
bucket.count += 1;
|
|
26
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
27
|
+
bucket.ms += ms;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
function emptyCost() {
|
|
16
32
|
return {
|
|
17
33
|
runs: 0,
|
|
18
34
|
reviewRounds: 0,
|
|
19
35
|
findings: 0,
|
|
20
36
|
humanWaits: 0,
|
|
37
|
+
waits: emptyWaits(),
|
|
21
38
|
infraFailures: 0,
|
|
22
39
|
workerMs: 0,
|
|
23
40
|
firstAt: null,
|
|
@@ -25,11 +42,25 @@ function emptyCost() {
|
|
|
25
42
|
};
|
|
26
43
|
}
|
|
27
44
|
|
|
28
|
-
// Cost facts of one ledger: review rounds, human waits
|
|
29
|
-
// worker wall time (STEP_STARTED → STEP_FINISHED per
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
// Cost facts of one ledger: review rounds, human waits and how each ended,
|
|
46
|
+
// infra failures and worker wall time (STEP_STARTED → STEP_FINISHED per
|
|
47
|
+
// attempt). A wait runs from HUMAN_REQUESTED to the next DECISION_RECORDED
|
|
48
|
+
// (decided) or to a RUN_TERMINAL without a decision (superseded or
|
|
49
|
+
// stopped); a run still waiting counts up to `now` (open). A request
|
|
50
|
+
// repeated before any decision (a resumed run asks again for the same
|
|
51
|
+
// decision) continues the same wait from the first request: counting it
|
|
52
|
+
// again would count the overlapping time twice.
|
|
53
|
+
export function ledgerCost(ledger, { now = Date.now() } = {}) {
|
|
54
|
+
const cost = {
|
|
55
|
+
reviewRounds: 0,
|
|
56
|
+
humanWaits: 0,
|
|
57
|
+
waits: emptyWaits(),
|
|
58
|
+
infraFailures: 0,
|
|
59
|
+
workerMs: 0,
|
|
60
|
+
};
|
|
32
61
|
const open = new Map();
|
|
62
|
+
let waitingSince = null;
|
|
63
|
+
let terminal = false;
|
|
33
64
|
for (const event of ledger.events) {
|
|
34
65
|
if (event.type === "STEP_STARTED") {
|
|
35
66
|
open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
|
|
@@ -51,12 +82,38 @@ export function ledgerCost(ledger) {
|
|
|
51
82
|
}
|
|
52
83
|
} else if (event.type === "HUMAN_REQUESTED") {
|
|
53
84
|
cost.humanWaits += 1;
|
|
85
|
+
waitingSince ??= Date.parse(event.ts);
|
|
86
|
+
} else if (event.type === "DECISION_RECORDED") {
|
|
87
|
+
if (waitingSince !== null) {
|
|
88
|
+
addWait(cost.waits.decided, Date.parse(event.ts) - waitingSince);
|
|
89
|
+
waitingSince = null;
|
|
90
|
+
}
|
|
91
|
+
} else if (event.type === "RUN_TERMINAL") {
|
|
92
|
+
terminal = true;
|
|
93
|
+
if (waitingSince !== null) {
|
|
94
|
+
const kind = event.data?.status === "SUPERSEDED" ? "superseded" : "stopped";
|
|
95
|
+
addWait(cost.waits[kind], Date.parse(event.ts) - waitingSince);
|
|
96
|
+
waitingSince = null;
|
|
97
|
+
}
|
|
54
98
|
}
|
|
55
99
|
}
|
|
100
|
+
if (waitingSince !== null && !terminal) {
|
|
101
|
+
addWait(cost.waits.open, now - waitingSince);
|
|
102
|
+
}
|
|
56
103
|
return cost;
|
|
57
104
|
}
|
|
58
105
|
|
|
59
|
-
|
|
106
|
+
function addWaits(total, waits) {
|
|
107
|
+
for (const kind of WAIT_KINDS) {
|
|
108
|
+
const row = waits?.[kind];
|
|
109
|
+
if (row && Number.isFinite(row.count) && Number.isFinite(row.ms)) {
|
|
110
|
+
total[kind].count += row.count;
|
|
111
|
+
total[kind].ms += row.ms;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function computeWorkCost(repoRoot, workId, { excludeRun = null, now = Date.now() } = {}) {
|
|
60
117
|
const cost = emptyCost();
|
|
61
118
|
const seen = new Set();
|
|
62
119
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
@@ -75,10 +132,11 @@ export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
|
|
|
75
132
|
if (state.run.id === excludeRun) {
|
|
76
133
|
continue;
|
|
77
134
|
}
|
|
78
|
-
const row = ledgerCost(ledger);
|
|
135
|
+
const row = ledgerCost(ledger, { now });
|
|
79
136
|
cost.runs += 1;
|
|
80
137
|
cost.reviewRounds += row.reviewRounds;
|
|
81
138
|
cost.humanWaits += row.humanWaits;
|
|
139
|
+
addWaits(cost.waits, row.waits);
|
|
82
140
|
cost.infraFailures += row.infraFailures;
|
|
83
141
|
cost.workerMs += row.workerMs;
|
|
84
142
|
track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
|
|
@@ -105,6 +163,8 @@ export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
|
|
|
105
163
|
// only; the cost block is preferred when present.
|
|
106
164
|
cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
|
|
107
165
|
cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
|
|
166
|
+
// Records written before wait kinds existed add no durations.
|
|
167
|
+
addWaits(cost.waits, record.cost?.waits);
|
|
108
168
|
cost.infraFailures += record.cost?.infraFailures ?? 0;
|
|
109
169
|
cost.workerMs += record.cost?.workerMs ?? 0;
|
|
110
170
|
track(cost, record.startedAt, record.finishedAt);
|
|
@@ -137,10 +197,13 @@ export function formatWorkerMs(ms) {
|
|
|
137
197
|
}
|
|
138
198
|
|
|
139
199
|
export function renderWorkCost(cost) {
|
|
200
|
+
const waits = WAIT_KINDS.filter((kind) => cost.waits?.[kind]?.count > 0).map(
|
|
201
|
+
(kind) => `${kind} ${formatWorkerMs(cost.waits[kind].ms)}`,
|
|
202
|
+
);
|
|
140
203
|
return [
|
|
141
204
|
`review rounds ${cost.reviewRounds}`,
|
|
142
205
|
`findings ${cost.findings}`,
|
|
143
|
-
`human waits ${cost.humanWaits}`,
|
|
206
|
+
`human waits ${cost.humanWaits}${waits.length > 0 ? ` (${waits.join(" · ")})` : ""}`,
|
|
144
207
|
...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
|
|
145
208
|
`worker ${formatWorkerMs(cost.workerMs)}`,
|
|
146
209
|
].join(" · ");
|