@haiyangbg/buildbeat 3.0.1 → 3.1.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 +20 -0
- package/SKILL.md +7 -4
- package/docs/README.md +1 -1
- package/docs/RELEASING.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 +11 -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/10-recovery.en.md +20 -4
- package/docs/v2/guide/10-recovery.md +20 -4
- package/package.json +1 -1
- package/src/v2/cli/run-config-check.js +223 -0
- package/src/v2/cli/run.js +74 -14
- package/src/v2/engine/reducer.js +6 -0
- package/src/v2/engine/yaml-subset.js +44 -9
- package/src/v2/runtime/decisions.js +32 -30
- package/src/v2/runtime/gc.js +28 -3
- package/src/v2/runtime/orchestrator.js +223 -96
- package/src/v2/storage/event-ledger.js +22 -3
- package/src/v2/workspace/workspace-manager.js +182 -13
- package/templates/v2/run-config.example.yaml +3 -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 = {};
|
|
@@ -495,7 +514,9 @@ async function commandStart(flags) {
|
|
|
495
514
|
const label = repoLabelFor(options.repoRoot);
|
|
496
515
|
const holders = listHeldRunLocks(options.repoRoot);
|
|
497
516
|
if (holders.length === 0) {
|
|
498
|
-
|
|
517
|
+
// A dead owner would already have been reclaimed; the error below
|
|
518
|
+
// names who holds the lock and what to do.
|
|
519
|
+
console.error("blocked by: the active-run lock alone (no run lock beside it); its owner is named below");
|
|
499
520
|
}
|
|
500
521
|
for (const holder of holders) {
|
|
501
522
|
const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
|
|
@@ -531,8 +552,42 @@ async function commandStart(flags) {
|
|
|
531
552
|
await notifyForState(options.repoRoot, repoLabel, ledger.state);
|
|
532
553
|
}
|
|
533
554
|
|
|
555
|
+
// Resolve only from runtime ledgers. Reading candidates does not acquire
|
|
556
|
+
// their locks; resumeRun still owns the lock and freshness checks.
|
|
557
|
+
function resolveResumeRun(repoRoot, family, explicitRun) {
|
|
558
|
+
const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d{2,}$`);
|
|
559
|
+
if (explicitRun !== undefined) {
|
|
560
|
+
if (explicitRun !== family && !pattern.test(explicitRun)) {
|
|
561
|
+
throw new Error(`--run ${explicitRun} is not in run family ${family} of this config`);
|
|
562
|
+
}
|
|
563
|
+
if (!existsSync(ledgerPathFor(repoRoot, explicitRun))) {
|
|
564
|
+
throw new Error(`no ledger for run ${explicitRun}`);
|
|
565
|
+
}
|
|
566
|
+
return explicitRun;
|
|
567
|
+
}
|
|
568
|
+
if (existsSync(ledgerPathFor(repoRoot, family))) {
|
|
569
|
+
return family;
|
|
570
|
+
}
|
|
571
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
572
|
+
const runs = (existsSync(runsDir) ? readdirSync(runsDir) : [])
|
|
573
|
+
.filter((id) => pattern.test(id) && existsSync(ledgerPathFor(repoRoot, id)))
|
|
574
|
+
.sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
|
|
575
|
+
.map((id) => ({ id, state: EventLedger.open(ledgerPathFor(repoRoot, id)).state }));
|
|
576
|
+
const open = runs.filter(({ state }) => !state.terminal);
|
|
577
|
+
if (open.length === 1) {
|
|
578
|
+
console.log(`resuming ${open[0].id} (the open run of family ${family})`);
|
|
579
|
+
return open[0].id;
|
|
580
|
+
}
|
|
581
|
+
if (open.length === 0) {
|
|
582
|
+
const latest = runs.at(-1);
|
|
583
|
+
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`);
|
|
584
|
+
}
|
|
585
|
+
throw new Error(`multiple open runs in family ${family}: ${open.map(({ id }) => id).join(", ")}; use --run <RUN-ID> to select one`);
|
|
586
|
+
}
|
|
587
|
+
|
|
534
588
|
async function commandResume(flags) {
|
|
535
589
|
const options = loadRunConfig(flags, "resume");
|
|
590
|
+
options.runId = resolveResumeRun(options.repoRoot, options.runId, flags.run);
|
|
536
591
|
if (flags.adopt !== undefined) {
|
|
537
592
|
const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
|
|
538
593
|
const adopted = adoptCandidate(options.repoRoot, options.runId, {
|
|
@@ -624,7 +679,7 @@ function commandApprove(flags) {
|
|
|
624
679
|
if (result.terminal) {
|
|
625
680
|
console.log("run is terminal: SUCCEEDED (merge itself stays a manual external action)");
|
|
626
681
|
} else {
|
|
627
|
-
console.log(
|
|
682
|
+
console.log(`decision recorded; continue with: buildbeat resume --config <run-config.yaml> --run ${flags.run}`);
|
|
628
683
|
}
|
|
629
684
|
}
|
|
630
685
|
|
|
@@ -954,16 +1009,21 @@ function commandStop(flags) {
|
|
|
954
1009
|
throw new Error("stop requires --repo and --run");
|
|
955
1010
|
}
|
|
956
1011
|
const repoRoot = resolve(flags.repo);
|
|
957
|
-
|
|
958
|
-
if (!ledger.state.run) {
|
|
1012
|
+
if (!existsSync(ledgerPathFor(repoRoot, flags.run))) {
|
|
959
1013
|
throw new Error(`no ledger for run ${flags.run}`);
|
|
960
1014
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
1015
|
+
// Read and decide under the run lock: a ledger read before it may be
|
|
1016
|
+
// stale by the time RUN_TERMINAL is written.
|
|
965
1017
|
acquireLock(repoRoot, flags.run);
|
|
966
1018
|
try {
|
|
1019
|
+
const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
|
|
1020
|
+
if (!ledger.state.run) {
|
|
1021
|
+
throw new Error(`no ledger for run ${flags.run}`);
|
|
1022
|
+
}
|
|
1023
|
+
if (ledger.state.terminal) {
|
|
1024
|
+
console.log(`run already terminal: ${ledger.state.terminal.status}`);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
967
1027
|
ledger.append({
|
|
968
1028
|
type: "RUN_TERMINAL",
|
|
969
1029
|
actor: KERNEL,
|
|
@@ -1008,7 +1068,7 @@ function commandGc(flags) {
|
|
|
1008
1068
|
if (action.kind === "delete-branch") {
|
|
1009
1069
|
return `delete branch ${action.branch} (${action.reason})`;
|
|
1010
1070
|
}
|
|
1011
|
-
return "remove stale lock";
|
|
1071
|
+
return action.owner ? "remove active-run lock (owner process is gone)" : "remove stale lock";
|
|
1012
1072
|
});
|
|
1013
1073
|
actionable += row.actions.length;
|
|
1014
1074
|
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,9 +142,17 @@ function parseList(lines, start, indent) {
|
|
|
127
142
|
}
|
|
128
143
|
const first = childLines[0].content;
|
|
129
144
|
const quotedScalar = first.startsWith('"') || first.startsWith("'");
|
|
130
|
-
|
|
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
|
+
);
|
|
153
|
+
}
|
|
131
154
|
result.push(parseScalar(first, childLines[0].lineNo));
|
|
132
|
-
} else if (
|
|
155
|
+
} else if (mapItem) {
|
|
133
156
|
result.push(parseMapFromLines(childLines, itemIndent));
|
|
134
157
|
} else {
|
|
135
158
|
throw new YamlSubsetError("unsupported list item shape", line.lineNo);
|
|
@@ -168,8 +191,19 @@ function parseMap(lines, start, indent) {
|
|
|
168
191
|
continue;
|
|
169
192
|
}
|
|
170
193
|
const childStart = index + 1;
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
const child = lines[childStart];
|
|
195
|
+
// A list may sit at its key's own indentation ("key:" then "- a").
|
|
196
|
+
if (child && child.indent === indent && (child.content === "-" || child.content.startsWith("- "))) {
|
|
197
|
+
const parsed = parseList(lines, childStart, indent);
|
|
198
|
+
result[key] = parsed.value;
|
|
199
|
+
index = parsed.next;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!child || child.indent <= indent) {
|
|
203
|
+
throw new YamlSubsetError(
|
|
204
|
+
`key "${key}" has no value: give it a value, write ${key}: [] for an empty list, or indent its items under it`,
|
|
205
|
+
line.lineNo,
|
|
206
|
+
);
|
|
173
207
|
}
|
|
174
208
|
const parsed = parseNode(lines, childStart, lines[childStart].indent);
|
|
175
209
|
result[key] = parsed.value;
|
|
@@ -179,7 +213,8 @@ function parseMap(lines, start, indent) {
|
|
|
179
213
|
}
|
|
180
214
|
|
|
181
215
|
export function parseYamlSubset(text) {
|
|
182
|
-
|
|
216
|
+
// A byte-order mark (Windows editors) is not content.
|
|
217
|
+
const lines = toLines(text.replace(/^\uFEFF/, ""));
|
|
183
218
|
if (lines.length === 0) {
|
|
184
219
|
throw new YamlSubsetError("empty document");
|
|
185
220
|
}
|
|
@@ -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,7 @@ 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 { describeLockOwner, inspectLock, readback, reclaimStaleLock } from "../workspace/workspace-manager.js";
|
|
22
23
|
import { resolveRepoRef } from "./repo-ref.js";
|
|
23
24
|
|
|
24
25
|
function git(cwd, args) {
|
|
@@ -64,6 +65,21 @@ export function planGc(repoRoot) {
|
|
|
64
65
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
65
66
|
const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
66
67
|
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: [] };
|
|
74
|
+
if (seen.state === "dead") {
|
|
75
|
+
row.actions.push({ kind: "remove-lock", path: activeLock, owner: seen.owner });
|
|
76
|
+
} 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");
|
|
78
|
+
} else {
|
|
79
|
+
row.keep.push(`active-run lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
|
|
80
|
+
}
|
|
81
|
+
rows.push(row);
|
|
82
|
+
}
|
|
67
83
|
if (!existsSync(runsDir)) {
|
|
68
84
|
return rows;
|
|
69
85
|
}
|
|
@@ -145,7 +161,16 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
|
|
|
145
161
|
const result = { run: row.run, ...action, done: false, error: null };
|
|
146
162
|
results.push(result);
|
|
147
163
|
try {
|
|
148
|
-
if (action.kind === "remove-lock") {
|
|
164
|
+
if (action.kind === "remove-lock" && action.owner) {
|
|
165
|
+
// Winning the takeover makes this process the only one entitled
|
|
166
|
+
// to the lock; only then is it removed.
|
|
167
|
+
if (reclaimStaleLock(action.path, action.owner)) {
|
|
168
|
+
rmSync(action.path, { recursive: true, force: true });
|
|
169
|
+
result.done = true;
|
|
170
|
+
} else {
|
|
171
|
+
result.error = "lock changed hands since the plan (or is being taken over); left in place";
|
|
172
|
+
}
|
|
173
|
+
} else if (action.kind === "remove-lock") {
|
|
149
174
|
rmSync(action.path, { recursive: true, force: true });
|
|
150
175
|
result.done = true;
|
|
151
176
|
} else if (action.kind === "remove-worktree") {
|