@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +40 -9
  2. package/SKILL.md +23 -300
  3. package/docs/CAPABILITY-MATRIX.md +1 -1
  4. package/docs/README.md +5 -5
  5. package/docs/RELEASING.md +5 -5
  6. package/docs/v2/RFC-0001-product-definition.md +5 -5
  7. package/docs/v2/RFC-0002-domain-model.md +1 -1
  8. package/docs/v2/RFC-0003-workflow-policy.md +2 -2
  9. package/docs/v2/SPEC-0001-events-v1.md +1 -1
  10. package/docs/v2/guide/01-quickstart.en.md +3 -1
  11. package/docs/v2/guide/01-quickstart.md +3 -1
  12. package/docs/v2/guide/02-workflow-guide.md +15 -0
  13. package/docs/v2/guide/07-approval-guide.en.md +4 -2
  14. package/docs/v2/guide/07-approval-guide.md +14 -2
  15. package/docs/v2/guide/09-security-boundaries.md +1 -1
  16. package/docs/v2/guide/10-recovery.en.md +21 -5
  17. package/docs/v2/guide/10-recovery.md +21 -5
  18. package/docs/v2/skill/01-principles.md +26 -0
  19. package/docs/v2/skill/02-project-layout.md +31 -0
  20. package/docs/v2/skill/03-collaboration-rules.md +45 -0
  21. package/docs/v2/skill/04-rhythm-and-rituals.md +102 -0
  22. package/docs/v2/skill/05-red-lines.md +13 -0
  23. package/docs/v2/skill/06-bootstrap-and-takeover.md +84 -0
  24. package/docs/v2/skill/07-templates-and-lessons.md +24 -0
  25. package/package.json +5 -14
  26. package/src/v2/cli/run-config-check.js +229 -0
  27. package/src/v2/cli/run.js +81 -15
  28. package/src/v2/engine/reducer.js +6 -0
  29. package/src/v2/engine/yaml-subset.js +52 -11
  30. package/src/v2/presets/policies/ui-render-gate.yaml +1 -1
  31. package/src/v2/runtime/decisions.js +33 -31
  32. package/src/v2/runtime/gc.js +59 -18
  33. package/src/v2/runtime/metrics.js +3 -2
  34. package/src/v2/runtime/orchestrator.js +308 -106
  35. package/src/v2/storage/event-ledger.js +22 -3
  36. package/src/v2/workspace/workspace-manager.js +244 -16
  37. package/templates/v2/CLAUDE.md +1 -1
  38. package/templates/v2/run-config.example.yaml +5 -1
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, readFileSync } from "node:fs";
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
15
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
 
@@ -52,6 +52,7 @@ import { resumeRun, startRun } from "../runtime/orchestrator.js";
52
52
  import { toRepoRef } from "../runtime/repo-ref.js";
53
53
  import { EventLedger } from "../storage/event-ledger.js";
54
54
  import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
55
+ import { checkRunConfigAgainstWorkflow, checkRunConfigShape, RunConfigError } from "./run-config-check.js";
55
56
 
56
57
  const KERNEL = { kind: "kernel", id: "cli" };
57
58
 
@@ -60,7 +61,7 @@ const USAGE = `BuildBeat runtime
60
61
  Usage:
61
62
  buildbeat --version
62
63
  buildbeat start --config <run-config.yaml> [--attempt new]
63
- buildbeat resume --config <run-config.yaml> [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
64
+ buildbeat resume --config <run-config.yaml> [--run <RUN-ID>] [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
64
65
  buildbeat status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
65
66
  buildbeat inbox --repo <path>
66
67
  buildbeat overview --repo <path> [--work <WORK-ID>] [--json true]
@@ -256,11 +257,29 @@ function loadRunConfig(flags, command) {
256
257
  }
257
258
  const configPath = resolve(flags.config);
258
259
  const config = parseYamlSubset(readFileSync(configPath, "utf8"));
260
+ // Validate before anything runs and list every problem at once: the
261
+ // workflow does not depend on repo, so its checks run whenever it loads.
262
+ const problems = checkRunConfigShape(config);
259
263
  const configDir = dirname(configPath);
264
+ let workflow = null;
265
+ let workflowPath = null;
266
+ let workflowText = null;
267
+ if (typeof config?.workflow === "string" && config.workflow.trim() !== "") {
268
+ workflowPath = resolve(configDir, config.workflow);
269
+ try {
270
+ workflowText = readFileSync(workflowPath, "utf8");
271
+ workflow = loadWorkflow(workflowPath);
272
+ } catch (error) {
273
+ problems.push(`workflow: cannot load ${config.workflow}: ${error.message}`);
274
+ }
275
+ }
276
+ if (workflow) {
277
+ problems.push(...checkRunConfigAgainstWorkflow(config, workflow));
278
+ }
279
+ if (problems.length > 0) {
280
+ throw new RunConfigError(flags.config, problems);
281
+ }
260
282
  const repoRoot = resolve(configDir, config.repo);
261
- const workflowPath = resolve(configDir, config.workflow);
262
- const workflowText = readFileSync(workflowPath, "utf8");
263
- const workflow = loadWorkflow(workflowPath);
264
283
  const workflowDigest = `sha256:${createHash("sha256").update(workflowText, "utf8").digest("hex")}`;
265
284
 
266
285
  const adapters = {};
@@ -387,6 +406,7 @@ function loadRunConfig(flags, command) {
387
406
  requires: config.requires ?? [],
388
407
  reviewTriage: config.reviewTriage === "required" ? "required" : null,
389
408
  supersede: config.supersede ?? "waiting",
409
+ parallel: config.parallel === true,
390
410
  stallAfterMs: config.stallAfterMs !== undefined ? Number(config.stallAfterMs) : DEFAULT_STALL_AFTER_MS,
391
411
  planDigest: digestOfWorkFile("plan.md"),
392
412
  intentDigest: digestOfWorkFile("intent.md"),
@@ -495,7 +515,9 @@ async function commandStart(flags) {
495
515
  const label = repoLabelFor(options.repoRoot);
496
516
  const holders = listHeldRunLocks(options.repoRoot);
497
517
  if (holders.length === 0) {
498
- console.error("blocked by: a stale active-run lock with no run holding it (a killed process?); `gc` clears locks of terminal runs, or remove .buildbeat/runtime/locks/active-run.lock after checking no driver process is alive");
518
+ // A dead owner would already have been reclaimed; the error below
519
+ // names who holds the lock and what to do.
520
+ console.error("blocked by: the active-run lock alone (no run lock beside it); its owner is named below");
499
521
  }
500
522
  for (const holder of holders) {
501
523
  const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
@@ -510,7 +532,7 @@ async function commandStart(flags) {
510
532
  console.error(`blocked by ${holder}: ${summary}`);
511
533
  console.error(` watch it: buildbeat status --repo ${label} --run ${holder}`);
512
534
  }
513
- console.error("queue position: next after the holder(s) above stop or wait on a human (the repository allows one driving run at a time; worktrees are already isolated)");
535
+ console.error("queue position: next after the holder(s) above stop or wait on a human (by default one run drives a repository at a time; works whose run configs both set parallel: true can drive together)");
514
536
  }
515
537
  throw error;
516
538
  }
@@ -531,8 +553,42 @@ async function commandStart(flags) {
531
553
  await notifyForState(options.repoRoot, repoLabel, ledger.state);
532
554
  }
533
555
 
556
+ // Resolve only from runtime ledgers. Reading candidates does not acquire
557
+ // their locks; resumeRun still owns the lock and freshness checks.
558
+ function resolveResumeRun(repoRoot, family, explicitRun) {
559
+ const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d{2,}$`);
560
+ if (explicitRun !== undefined) {
561
+ if (explicitRun !== family && !pattern.test(explicitRun)) {
562
+ throw new Error(`--run ${explicitRun} is not in run family ${family} of this config`);
563
+ }
564
+ if (!existsSync(ledgerPathFor(repoRoot, explicitRun))) {
565
+ throw new Error(`no ledger for run ${explicitRun}`);
566
+ }
567
+ return explicitRun;
568
+ }
569
+ if (existsSync(ledgerPathFor(repoRoot, family))) {
570
+ return family;
571
+ }
572
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
573
+ const runs = (existsSync(runsDir) ? readdirSync(runsDir) : [])
574
+ .filter((id) => pattern.test(id) && existsSync(ledgerPathFor(repoRoot, id)))
575
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
576
+ .map((id) => ({ id, state: EventLedger.open(ledgerPathFor(repoRoot, id)).state }));
577
+ const open = runs.filter(({ state }) => !state.terminal);
578
+ if (open.length === 1) {
579
+ console.log(`resuming ${open[0].id} (the open run of family ${family})`);
580
+ return open[0].id;
581
+ }
582
+ if (open.length === 0) {
583
+ const latest = runs.at(-1);
584
+ throw new Error(`no open run in family ${family}; ${latest ? `latest run ${latest.id}: ${latest.state.terminal.status}` : "no ledgers found"}; use --run <RUN-ID> to select an existing run explicitly`);
585
+ }
586
+ throw new Error(`multiple open runs in family ${family}: ${open.map(({ id }) => id).join(", ")}; use --run <RUN-ID> to select one`);
587
+ }
588
+
534
589
  async function commandResume(flags) {
535
590
  const options = loadRunConfig(flags, "resume");
591
+ options.runId = resolveResumeRun(options.repoRoot, options.runId, flags.run);
536
592
  if (flags.adopt !== undefined) {
537
593
  const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
538
594
  const adopted = adoptCandidate(options.repoRoot, options.runId, {
@@ -624,7 +680,7 @@ function commandApprove(flags) {
624
680
  if (result.terminal) {
625
681
  console.log("run is terminal: SUCCEEDED (merge itself stays a manual external action)");
626
682
  } else {
627
- console.log("decision recorded; continue with: run.js resume --config <run-config.yaml>");
683
+ console.log(`decision recorded; continue with: buildbeat resume --config <run-config.yaml> --run ${flags.run}`);
628
684
  }
629
685
  }
630
686
 
@@ -774,6 +830,11 @@ function commandDoctor(flags) {
774
830
  console.log("environment contract: none declared (implicit PATH facts stay unchecked)");
775
831
  }
776
832
  console.log(`supersede: ${options.supersede} (new run for the same work ${options.supersede === "off" ? "leaves" : "supersedes"} older WAITING_HUMAN runs)`);
833
+ console.log(
834
+ options.parallel
835
+ ? "concurrency: parallel (runs of other works with parallel: true may drive at the same time; runs of this work stay exclusive; verifiers must not share fixed ports or databases)"
836
+ : "concurrency: exclusive (default: one driving run per repository; set parallel: true to drive alongside other works)",
837
+ );
777
838
  console.log(`stall threshold: ${formatMs(options.stallAfterMs)} without worker output`);
778
839
  const { config: notify, error: notifyError } = notifyConfigFor(options.repoRoot);
779
840
  if (notifyError) {
@@ -954,16 +1015,21 @@ function commandStop(flags) {
954
1015
  throw new Error("stop requires --repo and --run");
955
1016
  }
956
1017
  const repoRoot = resolve(flags.repo);
957
- const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
958
- if (!ledger.state.run) {
1018
+ if (!existsSync(ledgerPathFor(repoRoot, flags.run))) {
959
1019
  throw new Error(`no ledger for run ${flags.run}`);
960
1020
  }
961
- if (ledger.state.terminal) {
962
- console.log(`run already terminal: ${ledger.state.terminal.status}`);
963
- return;
964
- }
1021
+ // Read and decide under the run lock: a ledger read before it may be
1022
+ // stale by the time RUN_TERMINAL is written.
965
1023
  acquireLock(repoRoot, flags.run);
966
1024
  try {
1025
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
1026
+ if (!ledger.state.run) {
1027
+ throw new Error(`no ledger for run ${flags.run}`);
1028
+ }
1029
+ if (ledger.state.terminal) {
1030
+ console.log(`run already terminal: ${ledger.state.terminal.status}`);
1031
+ return;
1032
+ }
967
1033
  ledger.append({
968
1034
  type: "RUN_TERMINAL",
969
1035
  actor: KERNEL,
@@ -1008,7 +1074,7 @@ function commandGc(flags) {
1008
1074
  if (action.kind === "delete-branch") {
1009
1075
  return `delete branch ${action.branch} (${action.reason})`;
1010
1076
  }
1011
- return "remove stale lock";
1077
+ return action.owner ? "remove active-run lock (owner process is gone)" : "remove stale lock";
1012
1078
  });
1013
1079
  actionable += row.actions.length;
1014
1080
  const keep = row.keep.map((reason) => `keep: ${reason}`);
@@ -114,6 +114,9 @@ export function applyEvent(state, event) {
114
114
  attempts: data.attempt,
115
115
  detail: null,
116
116
  infraAttempts: state.steps[data.step]?.infraAttempts ?? 0,
117
+ // Omit the new key for legacy events to preserve their exact state.
118
+ ...(state.steps[data.step]?.freeAttempts !== undefined
119
+ ? { freeAttempts: state.steps[data.step].freeAttempts } : {}),
117
120
  };
118
121
  next.currentStep = data.step;
119
122
  break;
@@ -132,6 +135,9 @@ export function applyEvent(state, event) {
132
135
  // output, exit 75) is not charged to the step's budget.
133
136
  next.steps[data.step].infraAttempts = (step.infraAttempts ?? 0) + 1;
134
137
  }
138
+ if (data.free === true) {
139
+ next.steps[data.step].freeAttempts = (step.freeAttempts ?? 0) + 1;
140
+ }
135
141
  next.currentStep = null;
136
142
  break;
137
143
  }
@@ -1,8 +1,10 @@
1
1
  // Fail-closed strict YAML subset parser for BuildBeat v2 config files.
2
- // Supports exactly what the official presets need: nested maps, block lists,
3
- // and plain/quoted scalars with space indentation. Everything else — tabs,
4
- // anchors, aliases, tags, block/flow scalars, multi-document streams,
5
- // duplicate keys — is rejected with a line number, never guessed at.
2
+ // Supports exactly what the official presets need: nested maps, block lists
3
+ // (indented under their key or at the key's own indentation), the empty
4
+ // inline [] and {}, and plain/quoted scalars with space indentation.
5
+ // Everything else — tabs, anchors, aliases, tags, block/flow scalars,
6
+ // non-empty inline collections, multi-document streams, duplicate keys — is
7
+ // rejected with a line number and a way to rewrite it, never guessed at.
6
8
 
7
9
  export class YamlSubsetError extends Error {
8
10
  constructor(message, lineNo) {
@@ -13,6 +15,9 @@ export class YamlSubsetError extends Error {
13
15
  }
14
16
 
15
17
  const KEY_PATTERN = /^[A-Za-z0-9_.-]+$/;
18
+ // A list item is a map only when it starts like one: a valid key, then ": "
19
+ // or a colon at the end of the line.
20
+ const MAP_ITEM = /^[A-Za-z0-9_.-]+:( |$)/;
16
21
  const FORBIDDEN_SCALAR_START = ["&", "*", "!", "|", ">", "{", "[", "%", "@", "`"];
17
22
 
18
23
  function parseScalar(raw, lineNo) {
@@ -37,6 +42,14 @@ function parseScalar(raw, lineNo) {
37
42
  }
38
43
  return inner;
39
44
  }
45
+ if (/^\[\s*\]$/.test(text)) return [];
46
+ if (/^\{\s*\}$/.test(text)) return {};
47
+ if (text[0] === "[" || text[0] === "{") {
48
+ throw new YamlSubsetError(
49
+ `inline lists/maps are not supported except [] and {}; write one "- item" per line (or "key: value" per line): ${text}`,
50
+ lineNo,
51
+ );
52
+ }
40
53
  if (FORBIDDEN_SCALAR_START.includes(text[0])) {
41
54
  throw new YamlSubsetError(`unsupported YAML syntax at: ${text}`, lineNo);
42
55
  }
@@ -46,6 +59,8 @@ function parseScalar(raw, lineNo) {
46
59
  if (text === "true") return true;
47
60
  if (text === "false") return false;
48
61
  if (text === "null" || text === "~") return null;
62
+ // Leading zeros are kept as written (007 would otherwise silently be 7).
63
+ if (/^-?0\d+$/.test(text)) return text;
49
64
  if (/^-?\d+$/.test(text)) return Number.parseInt(text, 10);
50
65
  if (/^-?\d+\.\d+$/.test(text)) return Number.parseFloat(text);
51
66
  return text;
@@ -127,12 +142,26 @@ function parseList(lines, start, indent) {
127
142
  }
128
143
  const first = childLines[0].content;
129
144
  const quotedScalar = first.startsWith('"') || first.startsWith("'");
130
- if (childLines.length === 1 && (quotedScalar || !first.includes(":"))) {
131
- result.push(parseScalar(first, childLines[0].lineNo));
132
- } else if (!quotedScalar && first.includes(":")) {
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);
151
+ }
152
+ index = cursor;
153
+ continue;
154
+ }
155
+ if (!quotedScalar && MAP_ITEM.test(first)) {
133
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
+ );
134
163
  } else {
135
- throw new YamlSubsetError("unsupported list item shape", line.lineNo);
164
+ result.push(parseScalar(first, childLines[0].lineNo));
136
165
  }
137
166
  index = cursor;
138
167
  }
@@ -168,8 +197,19 @@ function parseMap(lines, start, indent) {
168
197
  continue;
169
198
  }
170
199
  const childStart = index + 1;
171
- if (childStart >= lines.length || lines[childStart].indent <= indent) {
172
- throw new YamlSubsetError(`key "${key}" has no value`, line.lineNo);
200
+ const child = lines[childStart];
201
+ // A list may sit at its key's own indentation ("key:" then "- a").
202
+ if (child && child.indent === indent && (child.content === "-" || child.content.startsWith("- "))) {
203
+ const parsed = parseList(lines, childStart, indent);
204
+ result[key] = parsed.value;
205
+ index = parsed.next;
206
+ continue;
207
+ }
208
+ if (!child || child.indent <= indent) {
209
+ throw new YamlSubsetError(
210
+ `key "${key}" has no value: give it a value, write ${key}: [] for an empty list, or indent its items under it`,
211
+ line.lineNo,
212
+ );
173
213
  }
174
214
  const parsed = parseNode(lines, childStart, lines[childStart].indent);
175
215
  result[key] = parsed.value;
@@ -179,7 +219,8 @@ function parseMap(lines, start, indent) {
179
219
  }
180
220
 
181
221
  export function parseYamlSubset(text) {
182
- const lines = toLines(text);
222
+ // A byte-order mark (Windows editors) is not content.
223
+ const lines = toLines(text.replace(/^\uFEFF/, ""));
183
224
  if (lines.length === 0) {
184
225
  throw new YamlSubsetError("empty document");
185
226
  }
@@ -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
 
@@ -54,20 +54,22 @@ function recordDecisionFile(repoRoot, work, line) {
54
54
  }
55
55
 
56
56
  export function approveRun(repoRoot, runId, { by = "human", transition, ts, policies } = {}) {
57
- const ledger = openWaiting(repoRoot, runId);
58
- const pending = ledger.state.pendingHuman;
59
- if (!transition) {
60
- throw new DecisionError(
61
- `an approval must name its transition explicitly (pending: ${pending.transition})`,
62
- );
63
- }
64
- if (transition !== pending.transition) {
65
- throw new DecisionError(
66
- `transition mismatch: pending is ${pending.transition}, got ${transition}`,
67
- );
68
- }
57
+ // Read, check and write under the run lock: a ledger read before the
58
+ // lock may be stale by the time it is written to.
69
59
  acquireLock(repoRoot, runId);
70
60
  try {
61
+ const ledger = openWaiting(repoRoot, runId);
62
+ const pending = ledger.state.pendingHuman;
63
+ if (!transition) {
64
+ throw new DecisionError(
65
+ `an approval must name its transition explicitly (pending: ${pending.transition})`,
66
+ );
67
+ }
68
+ if (transition !== pending.transition) {
69
+ throw new DecisionError(
70
+ `transition mismatch: pending is ${pending.transition}, got ${transition}`,
71
+ );
72
+ }
71
73
  const bound = ledger.state.workspaces[runId];
72
74
  const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
73
75
  if (!bound || !existsSync(worktreePath)) {
@@ -194,19 +196,19 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
194
196
  // fixes). The commit must already be the worktree HEAD: git is read back,
195
197
  // the claim is not trusted.
196
198
  export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
197
- if (!sha || typeof sha !== "string" || sha.length < 7) {
198
- throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
199
- }
200
- if (!resumeAt) {
201
- throw new DecisionError("adopt requires the step to resume at (resumeAt)");
202
- }
203
- const ledger = openWaiting(repoRoot, runId);
204
- const pending = ledger.state.pendingHuman;
205
- if (pending.kind === "final-decision") {
206
- throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
207
- }
208
199
  acquireLock(repoRoot, runId);
209
200
  try {
201
+ if (!sha || typeof sha !== "string" || sha.length < 7) {
202
+ throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
203
+ }
204
+ if (!resumeAt) {
205
+ throw new DecisionError("adopt requires the step to resume at (resumeAt)");
206
+ }
207
+ const ledger = openWaiting(repoRoot, runId);
208
+ const pending = ledger.state.pendingHuman;
209
+ if (pending.kind === "final-decision") {
210
+ throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
211
+ }
210
212
  const bound = ledger.state.workspaces[runId];
211
213
  const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
212
214
  if (!bound || !existsSync(worktreePath)) {
@@ -293,15 +295,15 @@ export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts }
293
295
  }
294
296
 
295
297
  export function rejectRun(repoRoot, runId, { by = "human", transition, reason, ts } = {}) {
296
- const ledger = openWaiting(repoRoot, runId);
297
- const pending = ledger.state.pendingHuman;
298
- if (transition && transition !== pending.transition) {
299
- throw new DecisionError(
300
- `transition mismatch: pending is ${pending.transition}, got ${transition}`,
301
- );
302
- }
303
298
  acquireLock(repoRoot, runId);
304
299
  try {
300
+ const ledger = openWaiting(repoRoot, runId);
301
+ const pending = ledger.state.pendingHuman;
302
+ if (transition && transition !== pending.transition) {
303
+ throw new DecisionError(
304
+ `transition mismatch: pending is ${pending.transition}, got ${transition}`,
305
+ );
306
+ }
305
307
  const when = ts ?? new Date().toISOString();
306
308
  const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
307
309
  ledger.append({
@@ -1,5 +1,6 @@
1
1
  // Runtime garbage collection (iteration 08, C3): terminal runs leave a
2
- // worktree, a run/* branch and sometimes a lock behind. Sixteen of them had
2
+ // worktree, a run/* branch and sometimes a lock behind (and a killed driver
3
+ // an active-run lock whose owner is gone). Sixteen of them had
3
4
  // piled up in the deploy campaign before the owner asked for "打扫卫生".
4
5
  //
5
6
  // Rules (fail-closed toward keeping things):
@@ -18,7 +19,14 @@ import { join } from "node:path";
18
19
  import { execFileSync } from "node:child_process";
19
20
 
20
21
  import { EventLedger } from "../storage/event-ledger.js";
21
- import { readback } 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";
22
30
  import { resolveRepoRef } from "./repo-ref.js";
23
31
 
24
32
  function git(cwd, args) {
@@ -64,6 +72,28 @@ export function planGc(repoRoot) {
64
72
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
65
73
  const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
66
74
  const rows = [];
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: [] };
88
+ if (seen.state === "dead") {
89
+ row.actions.push({ kind: "remove-lock", path: lockPath, owner: seen.owner });
90
+ } else if (seen.state === "unknown") {
91
+ row.keep.push(`${name} lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running`);
92
+ } else {
93
+ row.keep.push(`${name} lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
94
+ }
95
+ rows.push(row);
96
+ }
67
97
  if (!existsSync(runsDir)) {
68
98
  return rows;
69
99
  }
@@ -145,7 +175,16 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
145
175
  const result = { run: row.run, ...action, done: false, error: null };
146
176
  results.push(result);
147
177
  try {
148
- if (action.kind === "remove-lock") {
178
+ if (action.kind === "remove-lock" && action.owner) {
179
+ // Winning the takeover makes this process the only one entitled
180
+ // to the lock; only then is it removed.
181
+ if (reclaimStaleLock(action.path, action.owner)) {
182
+ rmSync(action.path, { recursive: true, force: true });
183
+ result.done = true;
184
+ } else {
185
+ result.error = "lock changed hands since the plan (or is being taken over); left in place";
186
+ }
187
+ } else if (action.kind === "remove-lock") {
149
188
  rmSync(action.path, { recursive: true, force: true });
150
189
  result.done = true;
151
190
  } else if (action.kind === "remove-worktree") {
@@ -153,24 +192,26 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
153
192
  result.error = "worktree dirty; rerun with --force true to discard";
154
193
  continue;
155
194
  }
156
- if (action.registered) {
157
- const args = ["worktree", "remove"];
158
- if (force || action.dirty) {
159
- 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 });
160
205
  }
161
- args.push(action.path);
162
- git(repoRoot, args);
163
- } else if (action.present) {
164
- rmSync(action.path, { recursive: true, force: true });
165
- }
166
- try {
167
- git(repoRoot, ["worktree", "prune"]);
168
- } catch {
169
- // prune is best-effort
170
- }
206
+ try {
207
+ git(repoRoot, ["worktree", "prune"]);
208
+ } catch {
209
+ // prune is best-effort
210
+ }
211
+ });
171
212
  result.done = true;
172
213
  } else if (action.kind === "delete-branch") {
173
- git(repoRoot, ["branch", "-D", action.branch]);
214
+ withRepoGitLock(repoRoot, () => git(repoRoot, ["branch", "-D", action.branch]));
174
215
  result.done = true;
175
216
  }
176
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";