@haiyangbg/buildbeat 2.0.0-beta.4 → 2.0.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 +27 -1
- package/README.en.md +20 -1
- package/README.md +20 -1
- package/SKILL.md +14 -4
- package/docs/CLI.md +3 -2
- package/docs/RELEASING.md +1 -1
- package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -0
- package/docs/V2.0.0-BETA.5-RELEASE-EVIDENCE-2026-09-05.md +10 -0
- package/docs/v2/SPEC-0001-events-v1.md +5 -4
- package/docs/v2/guide/02-workflow-guide.md +34 -1
- package/docs/v2/guide/07-approval-guide.md +14 -0
- package/docs/v2/guide/10-recovery.md +3 -2
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +25 -0
- package/package.json +1 -1
- package/src/v2/adapters/mock.js +9 -2
- package/src/v2/cli/run.js +147 -6
- package/src/v2/domain/event-registry.js +1 -0
- package/src/v2/engine/reducer.js +27 -1
- package/src/v2/runtime/decisions.js +80 -0
- package/src/v2/runtime/orchestrator.js +122 -11
- package/src/v2/runtime/overview.js +56 -19
- package/src/v2/runtime/run-record.js +3 -0
- package/src/v2/runtime/work-cost.js +147 -0
- package/src/v2/workspace/workspace-manager.js +14 -1
- package/templates/contracts/PROTOCOL.md +4 -0
- package/templates/gitignore.template +5 -0
- package/templates/scripts/bus-check.sh +37 -12
- package/templates/v2/AGENTS.md +3 -2
|
@@ -16,12 +16,13 @@ import { join } from "node:path";
|
|
|
16
16
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
17
|
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
18
|
import { nextReply } from "./notify.js";
|
|
19
|
+
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
19
20
|
|
|
20
21
|
function sha256File(path) {
|
|
21
22
|
return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function readJsonl(path) {
|
|
25
|
+
export function readJsonl(path) {
|
|
25
26
|
if (!existsSync(path)) {
|
|
26
27
|
return [];
|
|
27
28
|
}
|
|
@@ -38,7 +39,7 @@ function readJsonl(path) {
|
|
|
38
39
|
.filter(Boolean);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function artifactStatus(workDir, decisions, artifact) {
|
|
42
|
+
export function artifactStatus(workDir, decisions, artifact) {
|
|
42
43
|
const path = join(workDir, `${artifact}.md`);
|
|
43
44
|
if (!existsSync(path)) {
|
|
44
45
|
return { exists: false, accepted: false, stale: false };
|
|
@@ -93,6 +94,8 @@ function runsFor(repoRoot, workId) {
|
|
|
93
94
|
candidate: state.workspaces[state.run.id]?.candidate ?? null,
|
|
94
95
|
createdAt: ledger.events[0]?.ts ?? null,
|
|
95
96
|
lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
|
|
97
|
+
workflow: state.run.workflowRef ?? null,
|
|
98
|
+
steps: Object.keys(state.steps),
|
|
96
99
|
state,
|
|
97
100
|
source: "runtime",
|
|
98
101
|
});
|
|
@@ -118,6 +121,8 @@ function runsFor(repoRoot, workId) {
|
|
|
118
121
|
candidate: record.workspaces?.[entry]?.candidate ?? null,
|
|
119
122
|
createdAt: record.startedAt ?? null,
|
|
120
123
|
lastAt: record.finishedAt ?? null,
|
|
124
|
+
workflow: record.workflow ?? null,
|
|
125
|
+
steps: Object.keys(record.attempts ?? {}),
|
|
121
126
|
state: null,
|
|
122
127
|
source: "run-record",
|
|
123
128
|
});
|
|
@@ -156,14 +161,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
156
161
|
const runs = runsFor(repoRoot, workId);
|
|
157
162
|
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
158
163
|
const latest = live[live.length - 1] ?? null;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
// "Merged" is a fact about any candidate of the work, not only the
|
|
165
|
+
// latest run's: a pilot's shipped candidate sat behind a CANCELLED run
|
|
166
|
+
// (its in-run review budget ran out and closure happened elsewhere) and
|
|
167
|
+
// overview reported the shipped work as STOPPED_CANCELLED.
|
|
168
|
+
const mergedRun =
|
|
169
|
+
[...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
|
|
170
|
+
const merged = Boolean(mergedRun);
|
|
171
|
+
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
172
|
+
const isReleaseLane = (run) =>
|
|
173
|
+
Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
|
|
174
|
+
|
|
175
|
+
// A Work is closed by an explicit row in decisions.jsonl:
|
|
176
|
+
// {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
|
|
177
|
+
// A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
|
|
178
|
+
// stale close row can never hide something that still needs a human.
|
|
179
|
+
const closure = [...decisions].reverse().find((row) => row.transition === "close-work");
|
|
180
|
+
const liveRun = latest && (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
|
|
163
181
|
|
|
164
182
|
let stage;
|
|
165
183
|
let next;
|
|
166
|
-
if (!
|
|
184
|
+
if (closure && !liveRun) {
|
|
185
|
+
stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
|
|
186
|
+
const result = typeof closure.subject?.result === "string" && closure.subject.result.length > 0 ? closure.subject.result : "see decisions.jsonl";
|
|
187
|
+
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
188
|
+
} else if (!intent.exists) {
|
|
167
189
|
stage = "NO_INTENT";
|
|
168
190
|
next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
|
|
169
191
|
} else if (!latest) {
|
|
@@ -181,7 +203,7 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
181
203
|
next =
|
|
182
204
|
configs.length > 0
|
|
183
205
|
? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
|
|
184
|
-
: `no run-config in delivery/work/${workId}: write one, or
|
|
206
|
+
: `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
|
|
185
207
|
}
|
|
186
208
|
} else if (latest.status === "RUNNING") {
|
|
187
209
|
stage = "RUNNING";
|
|
@@ -190,16 +212,21 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
190
212
|
stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
|
|
191
213
|
const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
|
|
192
214
|
next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
|
|
215
|
+
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
216
|
+
// A release-readback lane that reached wait-close and was approved is
|
|
217
|
+
// a closed release window, not "nothing to merge".
|
|
218
|
+
stage = "RELEASED";
|
|
219
|
+
next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
|
|
220
|
+
} else if (merged) {
|
|
221
|
+
stage = "MERGED";
|
|
222
|
+
next =
|
|
223
|
+
`candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}` +
|
|
224
|
+
(latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
|
|
193
225
|
} else if (latest.status === "SUCCEEDED") {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
stage = "MERGE_READY";
|
|
199
|
-
next = latest.candidate
|
|
200
|
-
? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
|
|
201
|
-
: "run succeeded without a candidate; nothing to merge";
|
|
202
|
-
}
|
|
226
|
+
stage = "MERGE_READY";
|
|
227
|
+
next = latest.candidate
|
|
228
|
+
? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
|
|
229
|
+
: "run succeeded without a candidate; nothing to merge";
|
|
203
230
|
} else {
|
|
204
231
|
stage = `STOPPED_${latest.status}`;
|
|
205
232
|
next = plan.accepted
|
|
@@ -214,10 +241,12 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
214
241
|
envFacts,
|
|
215
242
|
openFindings,
|
|
216
243
|
runs: runs.length,
|
|
244
|
+
cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
|
|
217
245
|
latest: latest
|
|
218
246
|
? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
|
|
219
247
|
: null,
|
|
220
248
|
merged,
|
|
249
|
+
mergedCandidate: mergedRun?.candidate ?? null,
|
|
221
250
|
next,
|
|
222
251
|
});
|
|
223
252
|
}
|
|
@@ -242,7 +271,8 @@ export function renderOverview(rows) {
|
|
|
242
271
|
for (const row of rows) {
|
|
243
272
|
lines.push(`${row.work} ${row.stage}`);
|
|
244
273
|
const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
|
|
245
|
-
|
|
274
|
+
const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
|
|
275
|
+
if (row.openFindings > 0 && !settled) {
|
|
246
276
|
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
247
277
|
// them without anyone recording a verdict. The number says "nobody
|
|
248
278
|
// ruled on these", which is exactly what a human should know.
|
|
@@ -252,8 +282,15 @@ export function renderOverview(rows) {
|
|
|
252
282
|
parts.push("env-facts ✓");
|
|
253
283
|
}
|
|
254
284
|
lines.push(` ${parts.join(" · ")}`);
|
|
285
|
+
if (row.cost) {
|
|
286
|
+
// What this work has already consumed across every run, superseded
|
|
287
|
+
// ones included: the number a "continue or cut" decision needs.
|
|
288
|
+
lines.push(` cost: ${renderWorkCost(row.cost)}`);
|
|
289
|
+
}
|
|
255
290
|
if (row.latest) {
|
|
256
|
-
const cand = row.latest.candidate
|
|
291
|
+
const cand = row.latest.candidate
|
|
292
|
+
? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
|
|
293
|
+
: "";
|
|
257
294
|
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
258
295
|
const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
|
|
259
296
|
lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
|
|
@@ -9,6 +9,7 @@ import { join, relative } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { canonicalJson } from "../storage/event-ledger.js";
|
|
11
11
|
import { normalizeRepoRef } from "./repo-ref.js";
|
|
12
|
+
import { ledgerCost } from "./work-cost.js";
|
|
12
13
|
|
|
13
14
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
14
15
|
|
|
@@ -40,12 +41,14 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
40
41
|
const record = {
|
|
41
42
|
run: first.run,
|
|
42
43
|
work: first.work,
|
|
44
|
+
workflow: state.run?.workflowRef ?? null,
|
|
43
45
|
terminal: state.terminal,
|
|
44
46
|
events: { from: first.seq, to: last.seq, lastDigest: last.digest },
|
|
45
47
|
startedAt: first.ts,
|
|
46
48
|
finishedAt: last.ts,
|
|
47
49
|
attempts,
|
|
48
50
|
budgets: state.budgets,
|
|
51
|
+
cost: ledgerCost(ledger),
|
|
49
52
|
workspaces,
|
|
50
53
|
evidence,
|
|
51
54
|
decisions: state.decisions,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Work-level cost (iteration 09): what a work has already consumed across
|
|
2
|
+
// every run of it, superseded ones included. Per-run budgets were bypassed
|
|
3
|
+
// by "one run per review round" (a pilot work ran 21 runs and 9 review
|
|
4
|
+
// rounds while the preset's two-round cap never fired), and a work that ate
|
|
5
|
+
// ten runs and a day was cut by the owner as "cost > benefit" with no
|
|
6
|
+
// number in front of them. Derived only: runtime ledgers first, run-records
|
|
7
|
+
// for runs whose runtime was wiped. Nothing is written.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
13
|
+
import { readFindingsAccount } from "./findings.js";
|
|
14
|
+
|
|
15
|
+
function emptyCost() {
|
|
16
|
+
return {
|
|
17
|
+
runs: 0,
|
|
18
|
+
reviewRounds: 0,
|
|
19
|
+
findings: 0,
|
|
20
|
+
humanWaits: 0,
|
|
21
|
+
infraFailures: 0,
|
|
22
|
+
workerMs: 0,
|
|
23
|
+
firstAt: null,
|
|
24
|
+
lastAt: null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Cost facts of one ledger: review rounds, human waits, infra failures and
|
|
29
|
+
// worker wall time (STEP_STARTED → STEP_FINISHED per attempt).
|
|
30
|
+
export function ledgerCost(ledger) {
|
|
31
|
+
const cost = { reviewRounds: 0, humanWaits: 0, infraFailures: 0, workerMs: 0 };
|
|
32
|
+
const open = new Map();
|
|
33
|
+
for (const event of ledger.events) {
|
|
34
|
+
if (event.type === "STEP_STARTED") {
|
|
35
|
+
open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
|
|
36
|
+
} else if (event.type === "STEP_FINISHED") {
|
|
37
|
+
const key = `${event.data.step}#${event.data.attempt}`;
|
|
38
|
+
const startedAt = open.get(key);
|
|
39
|
+
if (startedAt !== undefined) {
|
|
40
|
+
const ms = Date.parse(event.ts) - startedAt;
|
|
41
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
42
|
+
cost.workerMs += ms;
|
|
43
|
+
}
|
|
44
|
+
open.delete(key);
|
|
45
|
+
}
|
|
46
|
+
if (event.data.step === "review") {
|
|
47
|
+
cost.reviewRounds += 1;
|
|
48
|
+
}
|
|
49
|
+
if (event.data.infra === true) {
|
|
50
|
+
cost.infraFailures += 1;
|
|
51
|
+
}
|
|
52
|
+
} else if (event.type === "HUMAN_REQUESTED") {
|
|
53
|
+
cost.humanWaits += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return cost;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
|
|
60
|
+
const cost = emptyCost();
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
63
|
+
if (existsSync(runsDir)) {
|
|
64
|
+
for (const entry of readdirSync(runsDir)) {
|
|
65
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const ledger = EventLedger.open(path);
|
|
70
|
+
const state = ledger.state;
|
|
71
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(state.run.id);
|
|
75
|
+
if (state.run.id === excludeRun) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const row = ledgerCost(ledger);
|
|
79
|
+
cost.runs += 1;
|
|
80
|
+
cost.reviewRounds += row.reviewRounds;
|
|
81
|
+
cost.humanWaits += row.humanWaits;
|
|
82
|
+
cost.infraFailures += row.infraFailures;
|
|
83
|
+
cost.workerMs += row.workerMs;
|
|
84
|
+
track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
|
|
88
|
+
if (existsSync(recordsDir)) {
|
|
89
|
+
for (const entry of readdirSync(recordsDir)) {
|
|
90
|
+
if (seen.has(entry) || entry === excludeRun) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = join(recordsDir, entry, "run-record.json");
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let record;
|
|
98
|
+
try {
|
|
99
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
cost.runs += 1;
|
|
104
|
+
// Records written before iteration 09 carry attempts and decisions
|
|
105
|
+
// only; the cost block is preferred when present.
|
|
106
|
+
cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
|
|
107
|
+
cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
|
|
108
|
+
cost.infraFailures += record.cost?.infraFailures ?? 0;
|
|
109
|
+
cost.workerMs += record.cost?.workerMs ?? 0;
|
|
110
|
+
track(cost, record.startedAt, record.finishedAt);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
cost.findings = readFindingsAccount(repoRoot, workId).filter((row) => row.kind === "finding").length;
|
|
114
|
+
return cost;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function track(cost, firstAt, lastAt) {
|
|
118
|
+
if (firstAt && (!cost.firstAt || firstAt < cost.firstAt)) {
|
|
119
|
+
cost.firstAt = firstAt;
|
|
120
|
+
}
|
|
121
|
+
if (lastAt && (!cost.lastAt || lastAt > cost.lastAt)) {
|
|
122
|
+
cost.lastAt = lastAt;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatWorkerMs(ms) {
|
|
127
|
+
if (!ms || ms < 1000) {
|
|
128
|
+
return "0s";
|
|
129
|
+
}
|
|
130
|
+
if (ms < 60000) {
|
|
131
|
+
return `${Math.round(ms / 1000)}s`;
|
|
132
|
+
}
|
|
133
|
+
const totalMinutes = Math.round(ms / 60000);
|
|
134
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
135
|
+
const minutes = totalMinutes % 60;
|
|
136
|
+
return hours > 0 ? `${hours}h${String(minutes).padStart(2, "0")}m` : `${minutes}m`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderWorkCost(cost) {
|
|
140
|
+
return [
|
|
141
|
+
`review rounds ${cost.reviewRounds}`,
|
|
142
|
+
`findings ${cost.findings}`,
|
|
143
|
+
`human waits ${cost.humanWaits}`,
|
|
144
|
+
...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
|
|
145
|
+
`worker ${formatWorkerMs(cost.workerMs)}`,
|
|
146
|
+
].join(" · ");
|
|
147
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// deleted here — the pinned candidate must stay reachable for evidence.
|
|
5
5
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export class WorkspaceError extends Error {
|
|
@@ -44,6 +44,19 @@ export function acquireLock(repoRoot, runId) {
|
|
|
44
44
|
return lockPath;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Run ids currently holding a lock in this repository (the repository-wide
|
|
48
|
+
// active-run marker excluded): who a blocked `start` is queued behind.
|
|
49
|
+
export function listHeldRunLocks(repoRoot) {
|
|
50
|
+
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
51
|
+
if (!existsSync(lockDir)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return readdirSync(lockDir)
|
|
55
|
+
.filter((entry) => entry.endsWith(".lock") && entry !== "active-run.lock")
|
|
56
|
+
.map((entry) => entry.slice(0, -".lock".length))
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
export function releaseLock(repoRoot, runId) {
|
|
48
61
|
const lockPath = join(repoRoot, ".buildbeat", "runtime", "locks", `${runId}.lock`);
|
|
49
62
|
rmSync(lockPath, { recursive: true, force: true });
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<!-- buildbeat-multirepo-map:v1
|
|
14
14
|
repo=<代码子仓1>|contract=contracts/PROTOCOL.md|deployment=<bus-baseline.json app 名或 n/a>
|
|
15
15
|
-->
|
|
16
|
+
<!-- map 行格式:repo=<子仓路径>|contract=<contracts/*.md 或 n/a>|deployment=<bus-baseline.json app 名或 n/a>[|changelog=<该仓内模块 CHANGELOG 路径>]
|
|
17
|
+
· changelog= 给多模块仓用(根下没有 CHANGELOG,由某个模块 CHANGELOG 承载契约版本);缺省 <repo>/CHANGELOG.md。
|
|
18
|
+
· contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),只登记不核对;不得拿它掩盖真实的契约关系。
|
|
19
|
+
· 被核对的 CHANGELOG 首个已发布 H2 须以契约快照版本开头,如 `## [v1.3 · Deployed 2026-09-05 · <sha> · <流水线>]`。 -->
|
|
16
20
|
|
|
17
21
|
---
|
|
18
22
|
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
# verify-status 的「上次全绿」标记(本地实查产物,不入 git)
|
|
15
15
|
.last-green-*
|
|
16
16
|
|
|
17
|
+
# BuildBeat v2 运行时面与隔离工作树(可随时整删重建;不入 git)
|
|
18
|
+
# 同时让 rg / 尊重 .gitignore 的工具不再走进旧工作树;vitest / jest 等要另配 exclude,见 docs/v2/guide/02-workflow-guide.md
|
|
19
|
+
.buildbeat/runtime/
|
|
20
|
+
.buildbeat/worktrees/
|
|
21
|
+
|
|
17
22
|
# 代码子仓(各自独立 git,meta 仓不跟踪;按实际仓名替换)
|
|
18
23
|
/<代码仓1>/
|
|
19
24
|
/<代码仓2>/
|
|
@@ -438,14 +438,18 @@ check_multirepo_drift() {
|
|
|
438
438
|
multirepo_line="${multirepo_line%$'\r'}"
|
|
439
439
|
[ -n "$multirepo_line" ] || continue
|
|
440
440
|
if ! printf '%s\n' "$multirepo_line" \
|
|
441
|
-
| grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]
|
|
441
|
+
| grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]+(\|changelog=[^|]+)?$'; then
|
|
442
442
|
multirepo_map_invalid=1
|
|
443
443
|
continue
|
|
444
444
|
fi
|
|
445
|
-
IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field <<< "$multirepo_line"
|
|
445
|
+
IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field multirepo_changelog_field <<< "$multirepo_line"
|
|
446
446
|
multirepo_repo="${multirepo_repo_field#repo=}"
|
|
447
447
|
multirepo_contract="${multirepo_contract_field#contract=}"
|
|
448
448
|
multirepo_deployment="${multirepo_deployment_field#deployment=}"
|
|
449
|
+
# 可选第 4 字段 changelog=<repo 内的 CHANGELOG 路径>:多模块仓没有根 CHANGELOG 时,
|
|
450
|
+
# 由 map 显式指定承载契约版本的模块 CHANGELOG;缺省仍为 <repo>/CHANGELOG.md。
|
|
451
|
+
multirepo_changelog_override="${multirepo_changelog_field#changelog=}"
|
|
452
|
+
[ -n "$multirepo_changelog_field" ] || multirepo_changelog_override=""
|
|
449
453
|
multirepo_repo_trimmed="$(printf '%s' "$multirepo_repo" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
450
454
|
multirepo_contract_trimmed="$(printf '%s' "$multirepo_contract" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
451
455
|
multirepo_deployment_trimmed="$(printf '%s' "$multirepo_deployment" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
@@ -454,12 +458,23 @@ check_multirepo_drift() {
|
|
|
454
458
|
|| [ "$multirepo_deployment" != "$multirepo_deployment_trimmed" ] \
|
|
455
459
|
|| ! multirepo_repo_path_safe "$multirepo_repo" \
|
|
456
460
|
|| ! multirepo_map_value_safe "$multirepo_contract" 240 \
|
|
457
|
-
|| !
|
|
461
|
+
|| ! { [ "$multirepo_contract" = "n/a" ] \
|
|
462
|
+
|| printf '%s\n' "$multirepo_contract" | grep -Eq '^contracts/[^/].*\.md$'; } \
|
|
458
463
|
|| ! multirepo_map_value_safe "$multirepo_deployment" 100; then
|
|
459
464
|
multirepo_map_invalid=1
|
|
460
465
|
continue
|
|
461
466
|
fi
|
|
462
|
-
|
|
467
|
+
# contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),
|
|
468
|
+
# 只登记入 inventory、不做版本核对;不得用它掩盖真实存在的契约版本关系。
|
|
469
|
+
if [ -n "$multirepo_changelog_override" ]; then
|
|
470
|
+
if ! multirepo_repo_path_safe "$multirepo_changelog_override" \
|
|
471
|
+
|| [ "${#multirepo_changelog_override}" -gt 240 ] \
|
|
472
|
+
|| ! printf '%s\n' "$multirepo_changelog_override" | grep -Eq "^$(printf '%s' "$multirepo_repo" | sed 's/[.[\*^$]/\\&/g')/.+/CHANGELOG\.md$"; then
|
|
473
|
+
multirepo_map_invalid=1
|
|
474
|
+
continue
|
|
475
|
+
fi
|
|
476
|
+
fi
|
|
477
|
+
printf '%s\t%s\t%s\t%s\n' "$multirepo_repo" "$multirepo_contract" "$multirepo_deployment" "$multirepo_changelog_override" >> "$multirepo_records"
|
|
463
478
|
printf '%s\n' "$multirepo_repo" >> "$multirepo_expected"
|
|
464
479
|
done < "$multirepo_map_raw"
|
|
465
480
|
|
|
@@ -485,9 +500,9 @@ check_multirepo_drift() {
|
|
|
485
500
|
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo is absent from buildbeat-multirepo-map:v1." "$multirepo_repo"
|
|
486
501
|
done < "$multirepo_discovered"
|
|
487
502
|
|
|
488
|
-
while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment; do
|
|
503
|
+
while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment multirepo_changelog_override; do
|
|
489
504
|
[ -n "$multirepo_repo" ] || continue
|
|
490
|
-
multirepo_changelog="$multirepo_repo/CHANGELOG.md"
|
|
505
|
+
multirepo_changelog="${multirepo_changelog_override:-$multirepo_repo/CHANGELOG.md}"
|
|
491
506
|
multirepo_issue=0
|
|
492
507
|
multirepo_drift=0
|
|
493
508
|
multirepo_changelog_ok=0
|
|
@@ -544,6 +559,10 @@ check_multirepo_drift() {
|
|
|
544
559
|
;;
|
|
545
560
|
esac
|
|
546
561
|
|
|
562
|
+
if [ "$multirepo_contract" = "n/a" ] && [ "$multirepo_deployment" = "n/a" ]; then
|
|
563
|
+
echo " · $multirepo_repo 已登记,无契约/部署版本域(contract=n/a, deployment=n/a),不做版本核对"
|
|
564
|
+
continue
|
|
565
|
+
fi
|
|
547
566
|
if [ -L "$multirepo_changelog" ] \
|
|
548
567
|
|| { [ -e "$multirepo_changelog" ] \
|
|
549
568
|
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_changelog"; }; then
|
|
@@ -557,8 +576,8 @@ check_multirepo_drift() {
|
|
|
557
576
|
"the mapped CHANGELOG version source was not readable"
|
|
558
577
|
multirepo_issue=1
|
|
559
578
|
elif [ ! -f "$multirepo_changelog" ]; then
|
|
560
|
-
echo " ⚠️ $multirepo_repo 缺少可读 regular
|
|
561
|
-
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG
|
|
579
|
+
echo " ⚠️ $multirepo_repo 缺少可读 regular $multirepo_changelog"
|
|
580
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG version source: $multirepo_changelog." "$multirepo_changelog"
|
|
562
581
|
multirepo_issue=1
|
|
563
582
|
else
|
|
564
583
|
multirepo_changelog_version="$(read_changelog_head_version "$multirepo_changelog" || true)"
|
|
@@ -572,8 +591,14 @@ check_multirepo_drift() {
|
|
|
572
591
|
fi
|
|
573
592
|
|
|
574
593
|
multirepo_contract_rc=0
|
|
575
|
-
|
|
576
|
-
|
|
594
|
+
if [ "$multirepo_contract" = "n/a" ]; then
|
|
595
|
+
multirepo_contract_rc=0
|
|
596
|
+
else
|
|
597
|
+
validate_reference "$multirepo_contract" "$multirepo_map_path" || multirepo_contract_rc=$?
|
|
598
|
+
fi
|
|
599
|
+
if [ "$multirepo_contract" = "n/a" ]; then
|
|
600
|
+
:
|
|
601
|
+
elif [ "$multirepo_contract_rc" -ne 0 ]; then
|
|
577
602
|
echo " ⚠️ $multirepo_repo 的契约版本来源不可读:$multirepo_contract"
|
|
578
603
|
if [ "$multirepo_contract_rc" -eq 4 ]; then
|
|
579
604
|
add_scan_boundary "symlink" "$multirepo_contract" \
|
|
@@ -672,9 +697,9 @@ check_multirepo_drift() {
|
|
|
672
697
|
add_finding "sync.multirepo_drift" "conflict" "Version sources disagree for repo=$multirepo_repo: $multirepo_changelog=$multirepo_changelog_fact; $multirepo_contract=$multirepo_contract_fact; $multirepo_deployment_fact." "$multirepo_changelog"
|
|
673
698
|
elif [ "$multirepo_issue" -eq 0 ]; then
|
|
674
699
|
if [ "$multirepo_deployment" = "n/a" ]; then
|
|
675
|
-
echo " ✅ $multirepo_repo 多仓版本一致:
|
|
700
|
+
echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract (deployment=n/a)"
|
|
676
701
|
else
|
|
677
|
-
echo " ✅ $multirepo_repo 多仓版本一致:
|
|
702
|
+
echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract ↔ $multirepo_baseline#apps.$multirepo_deployment.imageTag"
|
|
678
703
|
fi
|
|
679
704
|
fi
|
|
680
705
|
done < "$multirepo_records"
|
package/templates/v2/AGENTS.md
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
## 0. v2 下工作怎么发生(一页流程)
|
|
9
9
|
|
|
10
|
-
1. **工作项**:每件事一个 `delivery/work/<WORK-ID>/`(`intent.md` 为什么做 + `plan.md` 怎么做,可选 `env-facts.md` 记踩出来的环境事实);被 digest 绑定接受(`buildbeat-v2 accept
|
|
10
|
+
1. **工作项**:每件事一个 `delivery/work/<WORK-ID>/`(`intent.md` 为什么做 + **止损线**(最多几个 Run / 几轮 review / 几小时,越线先问所有者"继续还是砍")+ `plan.md` 怎么做,可选 `env-facts.md` 记踩出来的环境事实);被 digest 绑定接受(`buildbeat-v2 accept`)前只是草稿、不产生义务。`overview` 的 `cost:` 行就是止损线的读数。
|
|
11
11
|
2. **代码工作跑 Run**:`buildbeat-v2 start --config <run-config.yaml> --attempt new` → 隔离 worktree 内 Build→Verify→Fix→Review 自动闭环 → **停在合并决定**。push、合并、部署永远是人批之后的人类动作。
|
|
12
12
|
3. **人怎么知道该做什么**:`buildbeat-v2 overview --repo .` 回答「每件事走到哪、下一步该谁」;`inbox` 只列等人批的 Run,每条后面附可复制的下一句命令;`status --run <RUN>` 回答「还在动吗、动了多久、卡没卡」。
|
|
13
13
|
4. **上线**:生产动作是人的;`release-readback` 预设 + `release` 风险预设把「做之前回读 → 人做 → 做之后回读 → 观察 → 人关窗」记成 L4 证据,任一步失败即停人批。
|
|
14
14
|
5. **observe 盯生产**:`buildbeat-v2 observe run --config .buildbeat/observe.yaml` 一次=一轮只读体检;异常分层(落账→只读诊断→intent 草稿入队 `delivery/observe/intents/`),草稿**绝不自动执行**,人用 `observe triage` 分诊。
|
|
15
15
|
6. **拍板台账**:平台级真实决策包一行进 `pm/decisions.md`;Run 级批准落各 Work 的 `decisions.jsonl`;finding 裁决落 `review-findings.jsonl`。契约在 `contracts/`。
|
|
16
16
|
7. **通知**:`.buildbeat/notify.yaml` 配一条通道(URL 只能来自环境变量),Run 停在人批 / 终态 / 疑似卡住会来找人。
|
|
17
|
-
8. **打扫**:终态 Run 留下的工作树用 `buildbeat-v2 gc --repo .`
|
|
17
|
+
8. **打扫**:终态 Run 留下的工作树用 `buildbeat-v2 gc --repo .` 清(默认只出计划)。工作树在仓内 `.buildbeat/worktrees/`:`.gitignore` 排除 `.buildbeat/runtime/` 与 `.buildbeat/worktrees/`,测试框架的收集范围也要排除 `**/.buildbeat/**`(vitest `exclude`、jest `testPathIgnorePatterns`、pytest `norecursedirs`),否则主干测试会把旧候选的用例一起跑。
|
|
18
|
+
9. **worker 环境事实(写进 worker prompt / 信封)**:worker 的沙箱通常**不能监听端口**,需要起服务或绑定 loopback 的集成测试交给 verify 步,worker 只跑单测与静态检查,不要反复尝试;PATH 只认 POSIX 工具(`grep -E` 不用 `rg`,`find` 不用 `fd`)或在 `requires:` 里声明;verify / 包装脚本发现环境不满足(命令不在 PATH、端口被占、后端 404)就 `exit 75`,内核会当基础设施故障停人、不派 fixer、不扣预算。
|
|
18
19
|
|
|
19
20
|
## 1. 工作包路由 —— Builder 端到端负责,会话按 AI 视角隔离
|
|
20
21
|
|