@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
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
962
|
-
|
|
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}`);
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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({
|
package/src/v2/runtime/gc.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
3
|
-
// forever doing precise work on
|
|
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";
|