@haiyangbg/buildbeat 3.1.0 → 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.
@@ -142,20 +142,26 @@ function parseList(lines, start, indent) {
142
142
  }
143
143
  const first = childLines[0].content;
144
144
  const quotedScalar = first.startsWith('"') || first.startsWith("'");
145
- const mapItem = !quotedScalar && MAP_ITEM.test(first);
146
- if (childLines.length === 1 && !mapItem) {
147
- // Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
148
- if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
149
- throw new YamlSubsetError(
150
- `list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
151
- line.lineNo,
152
- );
145
+ if (childLines.length > 1) {
146
+ // Multi-line items keep the previous rule and messages unchanged.
147
+ if (!quotedScalar && first.includes(":")) {
148
+ result.push(parseMapFromLines(childLines, itemIndent));
149
+ } else {
150
+ throw new YamlSubsetError("unsupported list item shape", line.lineNo);
153
151
  }
154
- result.push(parseScalar(first, childLines[0].lineNo));
155
- } else if (mapItem) {
152
+ index = cursor;
153
+ continue;
154
+ }
155
+ if (!quotedScalar && MAP_ITEM.test(first)) {
156
156
  result.push(parseMapFromLines(childLines, itemIndent));
157
+ } else if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
158
+ // Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
159
+ throw new YamlSubsetError(
160
+ `list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
161
+ line.lineNo,
162
+ );
157
163
  } else {
158
- throw new YamlSubsetError("unsupported list item shape", line.lineNo);
164
+ result.push(parseScalar(first, childLines[0].lineNo));
159
165
  }
160
166
  index = cursor;
161
167
  }
@@ -1,4 +1,4 @@
1
- # Invariant 22 (v1 lessons #3, machine form): a UI delivery's spec approval
1
+ # Invariant 22 (lessons.md「静态稿拍板 → 返工螺旋」, machine form): a UI delivery's spec approval
2
2
  # subject must include renderable proof — a screenshot with a digest —
3
3
  # before a human may stamp it. Static prose is not an approvable design.
4
4
  kind: policy
@@ -2,7 +2,7 @@
2
2
  // An approval is recorded only after re-reading the workspace and confirming
3
3
  // the subject is still exactly what the request showed — if the candidate
4
4
  // moved or the tree is dirty, the request is refreshed instead of stamped
5
- // (lessons #18: no rubber-stamping a moved target). Every decision lands both
5
+ // (lessons.md「读过期 race」: no rubber-stamping a moved target). Every decision lands both
6
6
  // as a DECISION_RECORDED event and as a line in the Git plane
7
7
  // (delivery/work/<work>/decisions.jsonl).
8
8
 
@@ -19,7 +19,14 @@ import { join } from "node:path";
19
19
  import { execFileSync } from "node:child_process";
20
20
 
21
21
  import { EventLedger } from "../storage/event-ledger.js";
22
- import { describeLockOwner, inspectLock, readback, reclaimStaleLock } from "../workspace/workspace-manager.js";
22
+ import {
23
+ describeLockOwner,
24
+ inspectLock,
25
+ isRunLockName,
26
+ readback,
27
+ reclaimStaleLock,
28
+ withRepoGitLock,
29
+ } from "../workspace/workspace-manager.js";
23
30
  import { resolveRepoRef } from "./repo-ref.js";
24
31
 
25
32
  function git(cwd, args) {
@@ -65,18 +72,25 @@ export function planGc(repoRoot) {
65
72
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
66
73
  const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
67
74
  const rows = [];
68
- // The repository-wide lock belongs to no run: reclaimable only when its
69
- // owner process is provably gone (same host, pid no longer exists).
70
- const activeLock = join(locksDir, "active-run.lock");
71
- if (existsSync(activeLock)) {
72
- const seen = inspectLock(activeLock);
73
- const row = { run: "(repository)", status: "active-run lock", actions: [], keep: [] };
75
+ // Locks that belong to no single run (active-run, and the @work /
76
+ // @parallel / @repo-git locks): reclaimable only when their owner process
77
+ // is provably gone (same host, pid no longer exists).
78
+ const sharedLocks = existsSync(locksDir)
79
+ ? readdirSync(locksDir)
80
+ .filter((entry) => entry.endsWith(".lock") && !isRunLockName(entry.slice(0, -".lock".length)))
81
+ .sort()
82
+ : [];
83
+ for (const entry of sharedLocks) {
84
+ const name = entry.slice(0, -".lock".length);
85
+ const lockPath = join(locksDir, entry);
86
+ const seen = inspectLock(lockPath);
87
+ const row = { run: "(repository)", status: `${name} lock`, actions: [], keep: [] };
74
88
  if (seen.state === "dead") {
75
- row.actions.push({ kind: "remove-lock", path: activeLock, owner: seen.owner });
89
+ row.actions.push({ kind: "remove-lock", path: lockPath, owner: seen.owner });
76
90
  } else if (seen.state === "unknown") {
77
- row.keep.push("active-run lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running");
91
+ row.keep.push(`${name} lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running`);
78
92
  } else {
79
- row.keep.push(`active-run lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
93
+ row.keep.push(`${name} lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
80
94
  }
81
95
  rows.push(row);
82
96
  }
@@ -178,24 +192,26 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
178
192
  result.error = "worktree dirty; rerun with --force true to discard";
179
193
  continue;
180
194
  }
181
- if (action.registered) {
182
- const args = ["worktree", "remove"];
183
- if (force || action.dirty) {
184
- args.push("--force");
195
+ withRepoGitLock(repoRoot, () => {
196
+ if (action.registered) {
197
+ const args = ["worktree", "remove"];
198
+ if (force || action.dirty) {
199
+ args.push("--force");
200
+ }
201
+ args.push(action.path);
202
+ git(repoRoot, args);
203
+ } else if (action.present) {
204
+ rmSync(action.path, { recursive: true, force: true });
185
205
  }
186
- args.push(action.path);
187
- git(repoRoot, args);
188
- } else if (action.present) {
189
- rmSync(action.path, { recursive: true, force: true });
190
- }
191
- try {
192
- git(repoRoot, ["worktree", "prune"]);
193
- } catch {
194
- // prune is best-effort
195
- }
206
+ try {
207
+ git(repoRoot, ["worktree", "prune"]);
208
+ } catch {
209
+ // prune is best-effort
210
+ }
211
+ });
196
212
  result.done = true;
197
213
  } else if (action.kind === "delete-branch") {
198
- git(repoRoot, ["branch", "-D", action.branch]);
214
+ withRepoGitLock(repoRoot, () => git(repoRoot, ["branch", "-D", action.branch]));
199
215
  result.done = true;
200
216
  }
201
217
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  // buildbeat metrics v0: local, read-only, derived entirely from run ledgers.
2
- // No collection, no upload (V2-PLAN §6 / lessons #8: without numbers you are
3
- // forever doing precise work on the wrong thing).
2
+ // No collection, no upload (V2-PLAN §6; lessons.md「流程只管"怎么做对",不管
3
+ // "做的是不是对的事"」: without numbers you are forever doing precise work on
4
+ // the wrong thing).
4
5
 
5
6
  import { existsSync, readdirSync } from "node:fs";
6
7
  import { join } from "node:path";
@@ -21,9 +21,12 @@ import { EventLedger, canonicalJson } from "../storage/event-ledger.js";
21
21
  import {
22
22
  acquireLock,
23
23
  createWorkspace,
24
+ describeLockOwner,
24
25
  listChangedPaths,
26
+ liveParallelMarkers,
25
27
  readback,
26
28
  releaseLock,
29
+ withRepoGitLock,
27
30
  } from "../workspace/workspace-manager.js";
28
31
  import { writeRunRecord } from "./run-record.js";
29
32
  import { computeWorkCost } from "./work-cost.js";
@@ -52,8 +55,14 @@ function sha256(text) {
52
55
  return `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
53
56
  }
54
57
 
55
- // MVP is single project, single active run: driving a run takes a
56
- // repository-wide lock in addition to the per-run lock.
58
+ // By default one run drives a repository at a time: it holds the
59
+ // repository-wide active-run lock for its whole drive. A run whose config
60
+ // sets `parallel: true` instead holds a per-work lock and a marker, passing
61
+ // the active-run lock only briefly as a gate, so runs of different works can
62
+ // drive together while runs of the same work stay exclusive. Real incident:
63
+ // a session waited 3h23m behind another work's run although worktrees were
64
+ // already isolated; parallelism is opt-in because verifiers that bind fixed
65
+ // ports or share a database would collide.
57
66
  const ACTIVE_LOCK = "active-run";
58
67
 
59
68
  function lockActive(repoRoot) {
@@ -70,17 +79,81 @@ function lockActive(repoRoot) {
70
79
  }
71
80
  }
72
81
 
73
- function withRunLocks(repoRoot, runId, fn) {
82
+ function lockActiveWaiting(repoRoot, waitMs) {
83
+ const deadline = Date.now() + waitMs;
84
+ for (;;) {
85
+ try {
86
+ acquireLock(repoRoot, ACTIVE_LOCK);
87
+ return;
88
+ } catch (error) {
89
+ if (!error.lock || Date.now() >= deadline) {
90
+ break;
91
+ }
92
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
93
+ }
94
+ }
74
95
  lockActive(repoRoot);
96
+ }
97
+
98
+ function holdRunLock(repoRoot, runId, fn) {
99
+ acquireLock(repoRoot, runId);
75
100
  try {
76
- acquireLock(repoRoot, runId);
101
+ return fn();
102
+ } finally {
103
+ releaseLock(repoRoot, runId);
104
+ }
105
+ }
106
+
107
+ function withRunLocks(repoRoot, runId, fn, { workId = null, parallel = false } = {}) {
108
+ if (!parallel) {
109
+ lockActive(repoRoot);
77
110
  try {
78
- return fn();
111
+ const running = liveParallelMarkers(repoRoot);
112
+ if (running.length > 0) {
113
+ throw new OrchestratorError(
114
+ `another run is active in this repository (parallel run(s) ${running
115
+ .map((marker) => (marker.owner ? `${marker.run}, ${describeLockOwner(marker.owner)}` : marker.run))
116
+ .join("; ")}); this run is exclusive (set parallel: true in its run config to drive alongside other works)`,
117
+ );
118
+ }
119
+ return holdRunLock(repoRoot, runId, fn);
79
120
  } finally {
80
- releaseLock(repoRoot, runId);
121
+ releaseLock(repoRoot, ACTIVE_LOCK);
122
+ }
123
+ }
124
+ if (!workId) {
125
+ throw new OrchestratorError("a parallel run needs its work id");
126
+ }
127
+ const workLock = `@work.${workId}`;
128
+ const marker = `@parallel.${runId}`;
129
+ try {
130
+ acquireLock(repoRoot, workLock);
131
+ } catch (error) {
132
+ const detail = error.lock?.detail;
133
+ throw new OrchestratorError(
134
+ `another run of ${workId} is active (runs of the same work never drive together)${detail ? `; ${detail}` : ""}`,
135
+ );
136
+ }
137
+ try {
138
+ // Gate: an exclusive run holds active-run for its whole drive, so a
139
+ // parallel run cannot slip in while it runs; the marker, created under
140
+ // the gate, is what an exclusive run checks before it starts. Another
141
+ // parallel run holds the gate for milliseconds, so a busy gate is waited
142
+ // for briefly before it is reported (two parallel starts at the same
143
+ // instant must both get through).
144
+ lockActiveWaiting(repoRoot, 2000);
145
+ try {
146
+ acquireLock(repoRoot, marker);
147
+ } finally {
148
+ releaseLock(repoRoot, ACTIVE_LOCK);
149
+ }
150
+ try {
151
+ return holdRunLock(repoRoot, runId, fn);
152
+ } finally {
153
+ releaseLock(repoRoot, marker);
81
154
  }
82
155
  } finally {
83
- releaseLock(repoRoot, ACTIVE_LOCK);
156
+ releaseLock(repoRoot, workLock);
84
157
  }
85
158
  }
86
159
 
@@ -926,7 +999,7 @@ export function startRun(options) {
926
999
  }
927
1000
 
928
1001
  return withRunLocks(repoRoot, runId, () => {
929
- const workspace = createWorkspace({ repoRoot, runId, base });
1002
+ const workspace = withRepoGitLock(repoRoot, () => createWorkspace({ repoRoot, runId, base }));
930
1003
  const context = makeContext(options, ledger, workspace);
931
1004
  const now = context.now;
932
1005
  const supersession =
@@ -974,7 +1047,7 @@ export function startRun(options) {
974
1047
  superseded: supersession.superseded,
975
1048
  supersedeSkipped: supersession.skipped,
976
1049
  };
977
- });
1050
+ }, { workId, parallel: options.parallel === true });
978
1051
  }
979
1052
 
980
1053
  function resumeStepFromTransition(transition) {
@@ -1038,7 +1111,9 @@ export function resumeRun(options) {
1038
1111
  // decided again on a ledger read under the locks: another session may
1039
1112
  // have approved, resumed or stopped the run in between, and writing
1040
1113
  // through the earlier read would fork the hash chain.
1041
- const outside = resumeTarget(options, openLedgerFor(repoRoot, runId));
1114
+ const outer = openLedgerFor(repoRoot, runId);
1115
+ const outerLedger = outer.ledger;
1116
+ const outside = resumeTarget(options, outer);
1042
1117
  if (outside.early) {
1043
1118
  return outside.early;
1044
1119
  }
@@ -1216,5 +1291,5 @@ export function resumeRun(options) {
1216
1291
  drive(context, startStep);
1217
1292
  }
1218
1293
  return { runId, ledgerPath, state: ledger.state, resumed: true, reason: null };
1219
- });
1294
+ }, { workId: outerLedger.state.run.work, parallel: options.parallel === true });
1220
1295
  }
@@ -214,19 +214,78 @@ export function acquireLock(repoRoot, runId) {
214
214
  return lockPath;
215
215
  }
216
216
 
217
- // Run ids currently holding a lock in this repository (the repository-wide
218
- // active-run marker excluded): who a blocked `start` is queued behind.
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.
219
226
  export function listHeldRunLocks(repoRoot) {
220
227
  const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
221
228
  if (!existsSync(lockDir)) {
222
229
  return [];
223
230
  }
224
231
  return readdirSync(lockDir)
225
- .filter((entry) => entry.endsWith(".lock") && entry !== "active-run.lock")
232
+ .filter((entry) => entry.endsWith(".lock"))
226
233
  .map((entry) => entry.slice(0, -".lock".length))
234
+ .filter(isRunLockName)
227
235
  .sort();
228
236
  }
229
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
+
230
289
  export function releaseLock(repoRoot, runId) {
231
290
  rmSync(lockPathFor(repoRoot, runId), { recursive: true, force: true });
232
291
  }
@@ -3,5 +3,5 @@
3
3
  本工作区的会话路由、协作规则、红线,**单点在同目录的 [`AGENTS.md`](AGENTS.md)** —— 请立即读取那份。
4
4
 
5
5
  > 本文件只为兼容「只认 `CLAUDE.md` 这个文件名的工具」而存在,**永远保持这几行**。
6
- > 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md` 第 1 条:SSOT 腐烂)。
6
+ > 往这里复制任何规则 = 两份文档必然漂移(上游 `lessons.md`「SSOT 腐烂)。
7
7
  > 也不要改成符号链接:Windows 上 git 默认 `core.symlinks=false`,clone 出来会静默退化成一个内容是路径字符串的普通文件,装载即失效。
@@ -23,6 +23,8 @@ budgets:
23
23
  maxAttempts:
24
24
  review: 2
25
25
  reviewRoundsPerWork: 6
26
+ # 默认一个仓库同时只驱动一个 Run。确认本项目的测试不抢固定端口、不共用数据库后,
27
+ # 可加 parallel: true,让本 Work 的 Run 与其他同样打开开关的 Work 并行(同一 Work 仍互斥)
26
28
  # 同树 + 同命令 + 同信封已通过就复用 verify 证据
27
29
  cache:
28
30
  verify: tree