@haiyangbg/buildbeat 3.0.1 → 3.2.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 +40 -9
- package/SKILL.md +23 -300
- package/docs/CAPABILITY-MATRIX.md +1 -1
- package/docs/README.md +5 -5
- package/docs/RELEASING.md +5 -5
- package/docs/v2/RFC-0001-product-definition.md +5 -5
- package/docs/v2/RFC-0002-domain-model.md +1 -1
- package/docs/v2/RFC-0003-workflow-policy.md +2 -2
- package/docs/v2/SPEC-0001-events-v1.md +1 -1
- package/docs/v2/guide/01-quickstart.en.md +3 -1
- package/docs/v2/guide/01-quickstart.md +3 -1
- package/docs/v2/guide/02-workflow-guide.md +15 -0
- package/docs/v2/guide/07-approval-guide.en.md +4 -2
- package/docs/v2/guide/07-approval-guide.md +14 -2
- package/docs/v2/guide/09-security-boundaries.md +1 -1
- package/docs/v2/guide/10-recovery.en.md +21 -5
- package/docs/v2/guide/10-recovery.md +21 -5
- package/docs/v2/skill/01-principles.md +26 -0
- package/docs/v2/skill/02-project-layout.md +31 -0
- package/docs/v2/skill/03-collaboration-rules.md +45 -0
- package/docs/v2/skill/04-rhythm-and-rituals.md +102 -0
- package/docs/v2/skill/05-red-lines.md +13 -0
- package/docs/v2/skill/06-bootstrap-and-takeover.md +84 -0
- package/docs/v2/skill/07-templates-and-lessons.md +24 -0
- package/package.json +5 -14
- package/src/v2/cli/run-config-check.js +229 -0
- package/src/v2/cli/run.js +81 -15
- package/src/v2/engine/reducer.js +6 -0
- package/src/v2/engine/yaml-subset.js +52 -11
- package/src/v2/presets/policies/ui-render-gate.yaml +1 -1
- package/src/v2/runtime/decisions.js +33 -31
- package/src/v2/runtime/gc.js +59 -18
- package/src/v2/runtime/metrics.js +3 -2
- package/src/v2/runtime/orchestrator.js +308 -106
- package/src/v2/storage/event-ledger.js +22 -3
- package/src/v2/workspace/workspace-manager.js +244 -16
- package/templates/v2/CLAUDE.md +1 -1
- package/templates/v2/run-config.example.yaml +5 -1
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// Workspace manager per docs/v2/RFC-0002-domain-model.md: every Run works in
|
|
2
2
|
// an isolated git worktree; the candidate is whatever git reads back, never
|
|
3
|
-
// what a worker claims. Locks are mkdir-atomic
|
|
4
|
-
//
|
|
3
|
+
// what a worker claims. Locks are mkdir-atomic and record their owner, so a
|
|
4
|
+
// lock whose owner process is provably gone can be reclaimed. Run branches
|
|
5
|
+
// are never deleted here — the pinned candidate must stay reachable for
|
|
6
|
+
// evidence.
|
|
5
7
|
|
|
6
8
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import {
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { hostname } from "node:os";
|
|
8
12
|
import { join } from "node:path";
|
|
9
13
|
|
|
10
14
|
export class WorkspaceError extends Error {
|
|
11
|
-
constructor(message) {
|
|
15
|
+
constructor(message, details = {}) {
|
|
12
16
|
super(message);
|
|
13
17
|
this.name = "WorkspaceError";
|
|
18
|
+
Object.assign(this, details);
|
|
14
19
|
}
|
|
15
20
|
}
|
|
16
21
|
|
|
@@ -29,37 +34,260 @@ function git(cwd, args) {
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
// Lock ownership. A driver killed by SIGKILL, a host-tool timeout or a
|
|
38
|
+
// reboot never reaches its finally block, so its locks outlive it. Real
|
|
39
|
+
// incident: a killed driver left active-run.lock and <RUN>.lock behind;
|
|
40
|
+
// resume answered "another run is active" and stop "already locked", and the
|
|
41
|
+
// only way out was deleting runtime directories by hand.
|
|
42
|
+
const OWNER_FILE = "owner.json";
|
|
43
|
+
const CLAIM_SLOTS = 16;
|
|
44
|
+
|
|
45
|
+
function lockPathFor(repoRoot, id) {
|
|
46
|
+
return join(repoRoot, ".buildbeat", "runtime", "locks", `${id}.lock`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pidAlive(pid) {
|
|
36
50
|
try {
|
|
37
|
-
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
38
53
|
} catch (error) {
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
// EPERM: the process exists but belongs to someone else.
|
|
55
|
+
return error.code === "EPERM";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readOwner(lockPath) {
|
|
60
|
+
try {
|
|
61
|
+
const owner = JSON.parse(readFileSync(join(lockPath, OWNER_FILE), "utf8"));
|
|
62
|
+
if (owner && Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string") {
|
|
63
|
+
return owner;
|
|
41
64
|
}
|
|
65
|
+
} catch {
|
|
66
|
+
// missing or unreadable: no owner record
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sameOwner(a, b) {
|
|
72
|
+
return a.pid === b.pid && a.host === b.host && a.acquiredAt === b.acquiredAt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function describeLockOwner(owner) {
|
|
76
|
+
return `pid ${owner.pid} on ${owner.host}, acquired ${owner.acquiredAt}${owner.command ? `, command ${owner.command}` : ""}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// alive | dead | foreign-host | unknown. Only "dead" is ever reclaimed: a
|
|
80
|
+
// foreign host cannot be probed, and a lock with no owner record may belong
|
|
81
|
+
// to an older buildbeat that is still running.
|
|
82
|
+
export function inspectLock(lockPath, { host = hostname(), isAlive = pidAlive } = {}) {
|
|
83
|
+
const owner = readOwner(lockPath);
|
|
84
|
+
if (!owner) {
|
|
85
|
+
return { state: "unknown", owner: null };
|
|
86
|
+
}
|
|
87
|
+
if (owner.host !== host) {
|
|
88
|
+
return { state: "foreign-host", owner };
|
|
89
|
+
}
|
|
90
|
+
return { state: isAlive(owner.pid) ? "alive" : "dead", owner };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ownerRecord() {
|
|
94
|
+
return {
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
host: hostname(),
|
|
97
|
+
acquiredAt: new Date().toISOString(),
|
|
98
|
+
command: process.argv[2] ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Readers never see a half-written owner: write aside, then rename over.
|
|
103
|
+
function writeOwner(lockPath) {
|
|
104
|
+
const temp = join(lockPath, `.${OWNER_FILE}.${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
105
|
+
writeFileSync(temp, `${JSON.stringify(ownerRecord())}\n`);
|
|
106
|
+
renameSync(temp, join(lockPath, OWNER_FILE));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function takeLock(lockPath) {
|
|
110
|
+
mkdirSync(lockPath);
|
|
111
|
+
try {
|
|
112
|
+
writeOwner(lockPath);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
42
115
|
throw error;
|
|
43
116
|
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ownerGeneration(owner) {
|
|
120
|
+
return createHash("sha256").update(`${owner.pid}|${owner.host}|${owner.acquiredAt}`).digest("hex").slice(0, 16);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A claim is abandoned only when its claimer is provably gone (this host,
|
|
124
|
+
// pid no longer exists). Never by age: a claimer paused by the scheduler or
|
|
125
|
+
// SIGSTOP is still alive and may still finish (review of the second version).
|
|
126
|
+
function claimAbandoned(claimPath, isAlive) {
|
|
127
|
+
try {
|
|
128
|
+
const claimer = JSON.parse(readFileSync(claimPath, "utf8"));
|
|
129
|
+
return claimer.host === hostname() && !isAlive(claimer.pid);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Creates the claim with its content already in place: the claimer is
|
|
136
|
+
// written aside and hard-linked to the claim name, and link() fails if the
|
|
137
|
+
// name exists. There is no instant in which a claim exists but is empty.
|
|
138
|
+
function createClaim(lockPath, claimPath) {
|
|
139
|
+
const temp = join(lockPath, `.claim.${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
140
|
+
writeFileSync(temp, JSON.stringify({ pid: process.pid, host: hostname() }));
|
|
141
|
+
try {
|
|
142
|
+
linkSync(temp, claimPath);
|
|
143
|
+
} finally {
|
|
144
|
+
unlinkSync(temp);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Wins the right to take over a lock whose owner was judged dead, or
|
|
149
|
+
// returns false. The lock directory never disappears during a takeover, so
|
|
150
|
+
// a plain acquire always sees it held and cannot slip in between (the
|
|
151
|
+
// review of the first version found exactly that window: renaming the lock
|
|
152
|
+
// aside let a third process take a fresh one). Reclaimers race on a claim
|
|
153
|
+
// file created by link() and named after the dead owner's generation:
|
|
154
|
+
// exactly one wins per generation. A winner that died before finishing
|
|
155
|
+
// leaves its claim behind; the next slot is tried once that claimer is
|
|
156
|
+
// provably gone. After winning, the owner record is re-read: if it is no
|
|
157
|
+
// longer the dead owner, nothing is taken over.
|
|
158
|
+
export function reclaimStaleLock(lockPath, expectedOwner, { isAlive = pidAlive } = {}) {
|
|
159
|
+
const generation = ownerGeneration(expectedOwner);
|
|
160
|
+
for (let slot = 1; slot <= CLAIM_SLOTS; slot += 1) {
|
|
161
|
+
const claimPath = join(lockPath, `claim-${generation}-${slot}`);
|
|
162
|
+
try {
|
|
163
|
+
createClaim(lockPath, claimPath);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code === "ENOENT") {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (error.code !== "EEXIST") {
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (claimAbandoned(claimPath, isAlive)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const current = readOwner(lockPath);
|
|
177
|
+
return Boolean(current && sameOwner(current, expectedOwner));
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function heldError(id, seen) {
|
|
183
|
+
const where = `.buildbeat/runtime/locks/${id}.lock`;
|
|
184
|
+
let detail;
|
|
185
|
+
if (seen.state === "alive") {
|
|
186
|
+
detail = `held by ${describeLockOwner(seen.owner)}; that process is still running: wait for it, or end it once you are sure it is stuck`;
|
|
187
|
+
} else if (seen.state === "dead") {
|
|
188
|
+
detail = `held by ${describeLockOwner(seen.owner)}, whose process is gone; another process is taking it over right now: retry in a moment`;
|
|
189
|
+
} else if (seen.state === "foreign-host") {
|
|
190
|
+
detail = `held by ${describeLockOwner(seen.owner)}, another host; release it there`;
|
|
191
|
+
} else {
|
|
192
|
+
detail = `no owner record in ${where} (a lock from an older buildbeat, or a crash while taking it); check that no buildbeat process is still running, then remove that directory`;
|
|
193
|
+
}
|
|
194
|
+
return new WorkspaceError(`run ${id} is already locked: ${detail}`, { lock: { id, ...seen, detail } });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function acquireLock(repoRoot, runId) {
|
|
198
|
+
mkdirSync(join(repoRoot, ".buildbeat", "runtime", "locks"), { recursive: true });
|
|
199
|
+
const lockPath = lockPathFor(repoRoot, runId);
|
|
200
|
+
try {
|
|
201
|
+
takeLock(lockPath);
|
|
202
|
+
return lockPath;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error.code !== "EEXIST") {
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const seen = inspectLock(lockPath);
|
|
209
|
+
if (seen.state !== "dead" || !reclaimStaleLock(lockPath, seen.owner)) {
|
|
210
|
+
throw heldError(runId, seen.state === "dead" ? inspectLock(lockPath) : seen);
|
|
211
|
+
}
|
|
212
|
+
writeOwner(lockPath);
|
|
213
|
+
process.stderr.write(`reclaimed stale lock ${runId} (owner ${describeLockOwner(seen.owner)} is gone)\n`);
|
|
44
214
|
return lockPath;
|
|
45
215
|
}
|
|
46
216
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
217
|
+
// Locks that belong to no single run: the repository-wide active-run lock,
|
|
218
|
+
// and names starting with "@" (per-work, parallel-run marker, repo-git),
|
|
219
|
+
// which no run id can contain.
|
|
220
|
+
export function isRunLockName(name) {
|
|
221
|
+
return name !== "active-run" && !name.startsWith("@");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Run ids currently holding a lock in this repository: who a blocked
|
|
225
|
+
// `start` is queued behind.
|
|
49
226
|
export function listHeldRunLocks(repoRoot) {
|
|
50
227
|
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
51
228
|
if (!existsSync(lockDir)) {
|
|
52
229
|
return [];
|
|
53
230
|
}
|
|
54
231
|
return readdirSync(lockDir)
|
|
55
|
-
.filter((entry) => entry.endsWith(".lock")
|
|
232
|
+
.filter((entry) => entry.endsWith(".lock"))
|
|
56
233
|
.map((entry) => entry.slice(0, -".lock".length))
|
|
234
|
+
.filter(isRunLockName)
|
|
57
235
|
.sort();
|
|
58
236
|
}
|
|
59
237
|
|
|
238
|
+
// Parallel-run markers (@parallel.<RUN>) whose owner is alive or cannot be
|
|
239
|
+
// judged; markers of dead owners are reclaimed on the way and not returned.
|
|
240
|
+
export function liveParallelMarkers(repoRoot) {
|
|
241
|
+
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
242
|
+
if (!existsSync(lockDir)) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const live = [];
|
|
246
|
+
for (const entry of readdirSync(lockDir).sort()) {
|
|
247
|
+
if (!entry.startsWith("@parallel.") || !entry.endsWith(".lock")) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const lockPath = join(lockDir, entry);
|
|
251
|
+
const seen = inspectLock(lockPath);
|
|
252
|
+
if (seen.state === "dead" && reclaimStaleLock(lockPath, seen.owner)) {
|
|
253
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
live.push({ run: entry.slice("@parallel.".length, -".lock".length), ...seen });
|
|
257
|
+
}
|
|
258
|
+
return live;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pause(ms) {
|
|
262
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Writes to the repository's shared git state (worktree add/remove, branch
|
|
266
|
+
// create/delete, .git/config) are serialised: with parallel runs two drivers
|
|
267
|
+
// could otherwise race on .git/config.lock or index.lock. Held for
|
|
268
|
+
// milliseconds, so a busy lock is waited for (bounded), not reported.
|
|
269
|
+
export function withRepoGitLock(repoRoot, fn, { waitMs = 10_000 } = {}) {
|
|
270
|
+
const deadline = Date.now() + waitMs;
|
|
271
|
+
for (;;) {
|
|
272
|
+
try {
|
|
273
|
+
acquireLock(repoRoot, "@repo-git");
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!error.lock || Date.now() >= deadline) {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
pause(100);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return fn();
|
|
284
|
+
} finally {
|
|
285
|
+
releaseLock(repoRoot, "@repo-git");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
60
289
|
export function releaseLock(repoRoot, runId) {
|
|
61
|
-
|
|
62
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
290
|
+
rmSync(lockPathFor(repoRoot, runId), { recursive: true, force: true });
|
|
63
291
|
}
|
|
64
292
|
|
|
65
293
|
export function createWorkspace({ repoRoot, runId, base, branch, protectPush = true }) {
|
package/templates/v2/CLAUDE.md
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
本工作区的会话路由、协作规则、红线,**单点在同目录的 [`AGENTS.md`](AGENTS.md)** —— 请立即读取那份。
|
|
4
4
|
|
|
5
5
|
> 本文件只为兼容「只认 `CLAUDE.md` 这个文件名的工具」而存在,**永远保持这几行**。
|
|
6
|
-
> 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md
|
|
6
|
+
> 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md`「SSOT 腐烂)。
|
|
7
7
|
> 也不要改成符号链接:Windows 上 git 默认 `core.symlinks=false`,clone 出来会静默退化成一个内容是路径字符串的普通文件,装载即失效。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
|
|
2
|
-
# 路径相对本文件解析。严格 YAML
|
|
2
|
+
# 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
|
|
3
3
|
# 起跑前:buildbeat doctor --config <本文件>;起跑:buildbeat start --config <本文件> --attempt new
|
|
4
4
|
repo: ../../..
|
|
5
5
|
work: WORK-X
|
|
@@ -16,11 +16,15 @@ allowedPaths:
|
|
|
16
16
|
- tests
|
|
17
17
|
# P0/P1 finding 先过人分诊再派 fixer;不需要就删掉这行
|
|
18
18
|
reviewTriage: required
|
|
19
|
+
# 非只读步成功不扣次数;review 按轮计费。到顶的阻断 review 在 enter-fix 一次批准修复、重验、再审。
|
|
20
|
+
# Run/Work 上限同时到顶只问一次;总 attempt 超过(配置值 + 人批扩额)的 3 倍前仍兜底停人。
|
|
19
21
|
# 可省;run 配置 > 预设 > 默认。reviewRoundsPerWork 是跨本 Work 所有 Run 累计的 review 轮数上限(对应 intent 的止损线)
|
|
20
22
|
budgets:
|
|
21
23
|
maxAttempts:
|
|
22
24
|
review: 2
|
|
23
25
|
reviewRoundsPerWork: 6
|
|
26
|
+
# 默认一个仓库同时只驱动一个 Run。确认本项目的测试不抢固定端口、不共用数据库后,
|
|
27
|
+
# 可加 parallel: true,让本 Work 的 Run 与其他同样打开开关的 Work 并行(同一 Work 仍互斥)
|
|
24
28
|
# 同树 + 同命令 + 同信封已通过就复用 verify 证据
|
|
25
29
|
cache:
|
|
26
30
|
verify: tree
|