@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.4
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 +29 -7
- package/SKILL.md +76 -2
- package/docs/CLI-PILOT-2026-08-23.md +1 -1
- package/docs/CLI.md +1 -1
- package/docs/EXECUTION-PLAN.md +2 -2
- package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
- package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
- package/docs/RELEASING.md +1 -1
- package/docs/V2-D2-DECISION-CARD.md +2 -2
- package/docs/V2-DECISIONS.md +2 -2
- package/docs/V2-ITERATION-01.md +13 -13
- package/docs/V2-ITERATION-06.md +2 -2
- package/docs/V2-ITERATION-08.md +62 -0
- package/docs/V2-PLAN.md +6 -6
- package/docs/V2-PROPOSAL.md +2 -2
- package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
- package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
- package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
- package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
- package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
- package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
- package/docs/v2/RFC-0001-product-definition.md +2 -2
- package/docs/v2/SPEC-0001-events-v1.md +2 -2
- package/docs/v2/guide/00-how-to-talk.md +57 -0
- package/docs/v2/guide/01-quickstart.md +4 -0
- package/docs/v2/guide/02-workflow-guide.md +14 -0
- package/docs/v2/guide/04-adapter-guide.md +4 -0
- package/docs/v2/guide/05-worker-contract.md +10 -0
- package/docs/v2/guide/06-evidence-guide.md +4 -0
- package/docs/v2/guide/07-approval-guide.md +33 -0
- package/docs/v2/guide/10-recovery.md +22 -1
- package/docs/v2/guide/README.md +3 -0
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +12 -0
- package/package.json +1 -1
- package/src/v2/adapters/shell.js +87 -14
- package/src/v2/cli/run.js +461 -25
- package/src/v2/engine/reducer.js +2 -0
- package/src/v2/engine/workflow.js +8 -1
- package/src/v2/evidence/collector.js +14 -3
- package/src/v2/presets/release-readback.yaml +36 -0
- package/src/v2/presets/risk/release.yaml +21 -0
- package/src/v2/runtime/cache.js +124 -0
- package/src/v2/runtime/env-contract.js +35 -1
- package/src/v2/runtime/envelope.js +183 -0
- package/src/v2/runtime/gc.js +182 -0
- package/src/v2/runtime/liveness.js +193 -0
- package/src/v2/runtime/metrics.js +8 -0
- package/src/v2/runtime/notify.js +223 -0
- package/src/v2/runtime/orchestrator.js +145 -8
- package/src/v2/runtime/overview.js +264 -0
- package/templates/v2/AGENTS.md +72 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Liveness and timing, derived only from the ledger and the live output
|
|
2
|
+
// streams the shell adapter leaves in the run directory. Nothing here is
|
|
3
|
+
// written to the ledger: "how long has this been running" and "when did the
|
|
4
|
+
// worker last print something" are readings, not facts a Run must carry.
|
|
5
|
+
//
|
|
6
|
+
// Real incident (deploy campaign, 2026-08-30): the owner asked "半小时了,
|
|
7
|
+
// 正常吗 / 十分钟了,是卡住了吗" more than ten times because status showed
|
|
8
|
+
// steps and attempts and nothing about time. This module is that answer.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
14
|
+
import { LIVE_MARKER } from "../adapters/shell.js";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_STALL_AFTER_MS = 15 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export function runDir(repoRoot, runId) {
|
|
19
|
+
return join(repoRoot, ".buildbeat", "runtime", "runs", runId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every step attempt as a timed row; the in-flight one (if any) has no end.
|
|
23
|
+
export function stepTimeline(events) {
|
|
24
|
+
const open = new Map();
|
|
25
|
+
const rows = [];
|
|
26
|
+
let running = null;
|
|
27
|
+
for (const event of events) {
|
|
28
|
+
if (event.type === "STEP_STARTED") {
|
|
29
|
+
const key = `${event.data.step}#${event.data.attempt}`;
|
|
30
|
+
open.set(key, { step: event.data.step, attempt: event.data.attempt, worker: event.data.worker, startedAt: event.ts });
|
|
31
|
+
running = open.get(key);
|
|
32
|
+
} else if (event.type === "STEP_FINISHED") {
|
|
33
|
+
const key = `${event.data.step}#${event.data.attempt}`;
|
|
34
|
+
const started = open.get(key);
|
|
35
|
+
const startedAt = started?.startedAt ?? null;
|
|
36
|
+
rows.push({
|
|
37
|
+
step: event.data.step,
|
|
38
|
+
attempt: event.data.attempt,
|
|
39
|
+
worker: started?.worker ?? null,
|
|
40
|
+
startedAt,
|
|
41
|
+
finishedAt: event.ts,
|
|
42
|
+
status: event.data.status,
|
|
43
|
+
durationMs: startedAt ? Date.parse(event.ts) - Date.parse(startedAt) : null,
|
|
44
|
+
});
|
|
45
|
+
open.delete(key);
|
|
46
|
+
if (running && running.step === event.data.step && running.attempt === event.data.attempt) {
|
|
47
|
+
running = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { rows, running };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function median(values) {
|
|
55
|
+
if (values.length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
59
|
+
const mid = Math.floor(sorted.length / 2);
|
|
60
|
+
return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Typical (median) duration per step across every readable ledger in the
|
|
64
|
+
// repository, optionally excluding the run being described. Crashed and
|
|
65
|
+
// timed-out attempts are excluded: they measure the host, not the step.
|
|
66
|
+
export function typicalDurations(repoRoot, { excludeRun = null } = {}) {
|
|
67
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
68
|
+
const samples = {};
|
|
69
|
+
if (!existsSync(runsDir)) {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
for (const entry of readdirSync(runsDir)) {
|
|
73
|
+
if (entry === excludeRun) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
77
|
+
if (!existsSync(ledgerPath)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
81
|
+
for (const row of stepTimeline(ledger.events).rows) {
|
|
82
|
+
if (row.durationMs === null || row.status === "crashed" || row.status === "timeout") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
(samples[row.step] ??= []).push(row.durationMs);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const [step, values] of Object.entries(samples)) {
|
|
90
|
+
result[step] = { medianMs: median(values), samples: values.length };
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reads the shell adapter's live marker and the mtime/size of its streams.
|
|
96
|
+
export function readLive(repoRoot, runId) {
|
|
97
|
+
const dir = runDir(repoRoot, runId);
|
|
98
|
+
const markerPath = join(dir, LIVE_MARKER);
|
|
99
|
+
if (!existsSync(markerPath)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let marker;
|
|
103
|
+
try {
|
|
104
|
+
marker = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
let lastOutputAt = null;
|
|
109
|
+
let bytes = 0;
|
|
110
|
+
for (const path of [marker.stdout, marker.stderr]) {
|
|
111
|
+
if (!path || !existsSync(path)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const stat = statSync(path);
|
|
115
|
+
bytes += stat.size;
|
|
116
|
+
if (stat.size > 0 && (lastOutputAt === null || stat.mtimeMs > lastOutputAt)) {
|
|
117
|
+
lastOutputAt = stat.mtimeMs;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { ...marker, lastOutputAt: lastOutputAt === null ? null : new Date(lastOutputAt).toISOString(), bytes };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function tailLive(repoRoot, runId, lines = 5) {
|
|
124
|
+
const live = readLive(repoRoot, runId);
|
|
125
|
+
if (!live) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const path of [live.stdout, live.stderr]) {
|
|
130
|
+
if (!path || !existsSync(path)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const text = readFileSync(path, "utf8");
|
|
134
|
+
out.push(...text.split("\n").filter((line) => line.trim().length > 0));
|
|
135
|
+
}
|
|
136
|
+
return out.slice(-lines);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function describeLiveness({ repoRoot, runId, ledger, stallAfterMs = DEFAULT_STALL_AFTER_MS, now = Date.now }) {
|
|
140
|
+
const { rows, running } = stepTimeline(ledger.events);
|
|
141
|
+
const typical = typicalDurations(repoRoot, { excludeRun: runId });
|
|
142
|
+
const steps = {};
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const entry = (steps[row.step] ??= { attempts: 0, totalMs: 0, lastMs: null, typicalMs: typical[row.step]?.medianMs ?? null, samples: typical[row.step]?.samples ?? 0 });
|
|
145
|
+
entry.attempts += 1;
|
|
146
|
+
if (row.durationMs !== null) {
|
|
147
|
+
entry.totalMs += row.durationMs;
|
|
148
|
+
entry.lastMs = row.durationMs;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
let inFlight = null;
|
|
152
|
+
if (running && ledger.state.run?.status === "RUNNING") {
|
|
153
|
+
const nowMs = now();
|
|
154
|
+
const live = readLive(repoRoot, runId);
|
|
155
|
+
const elapsedMs = nowMs - Date.parse(running.startedAt);
|
|
156
|
+
const lastOutputMs = live?.lastOutputAt ? Date.parse(live.lastOutputAt) : null;
|
|
157
|
+
const sinceOutputMs = lastOutputMs === null ? elapsedMs : nowMs - lastOutputMs;
|
|
158
|
+
inFlight = {
|
|
159
|
+
step: running.step,
|
|
160
|
+
attempt: running.attempt,
|
|
161
|
+
worker: running.worker,
|
|
162
|
+
startedAt: running.startedAt,
|
|
163
|
+
elapsedMs,
|
|
164
|
+
command: live?.command ?? null,
|
|
165
|
+
lastOutputAt: live?.lastOutputAt ?? null,
|
|
166
|
+
sinceOutputMs,
|
|
167
|
+
bytes: live?.bytes ?? 0,
|
|
168
|
+
stalled: sinceOutputMs >= stallAfterMs,
|
|
169
|
+
stallAfterMs,
|
|
170
|
+
typicalMs: typical[running.step]?.medianMs ?? null,
|
|
171
|
+
samples: typical[running.step]?.samples ?? 0,
|
|
172
|
+
};
|
|
173
|
+
steps[running.step] ??= { attempts: 0, totalMs: 0, lastMs: null, typicalMs: inFlight.typicalMs, samples: inFlight.samples };
|
|
174
|
+
}
|
|
175
|
+
return { steps, inFlight };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatMs(ms) {
|
|
179
|
+
if (ms === null || ms === undefined || Number.isNaN(ms)) {
|
|
180
|
+
return "?";
|
|
181
|
+
}
|
|
182
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
183
|
+
if (total < 60) {
|
|
184
|
+
return `${total}s`;
|
|
185
|
+
}
|
|
186
|
+
const minutes = Math.floor(total / 60);
|
|
187
|
+
if (minutes < 60) {
|
|
188
|
+
return `${minutes}m`;
|
|
189
|
+
}
|
|
190
|
+
const hours = Math.floor(minutes / 60);
|
|
191
|
+
const rest = minutes % 60;
|
|
192
|
+
return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, "0")}m`;
|
|
193
|
+
}
|
|
@@ -6,6 +6,7 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
|
|
8
8
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
9
|
+
import { formatMs, typicalDurations } from "./liveness.js";
|
|
9
10
|
|
|
10
11
|
export function computeMetrics(repoRoot) {
|
|
11
12
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
@@ -105,6 +106,9 @@ export function computeMetrics(repoRoot) {
|
|
|
105
106
|
summary.autoReachedHumanRate = measurable === 0 ? null : autoReached / measurable;
|
|
106
107
|
summary.evidenceCompleteness =
|
|
107
108
|
summary.finishedSteps === 0 ? null : evidencedSteps / summary.finishedSteps;
|
|
109
|
+
// Typical step duration (median over finished attempts, crashes and
|
|
110
|
+
// timeouts excluded): the number behind "is this taking too long".
|
|
111
|
+
summary.stepDurations = typicalDurations(repoRoot);
|
|
108
112
|
return summary;
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -136,5 +140,9 @@ export function renderMetrics(summary) {
|
|
|
136
140
|
lines.push("approval waits: (none recorded)");
|
|
137
141
|
}
|
|
138
142
|
lines.push(`stale approvals: ${summary.staleApprovals}; budget stops: ${summary.budgetStops}`);
|
|
143
|
+
const durations = Object.entries(summary.stepDurations ?? {})
|
|
144
|
+
.map(([step, row]) => `${step}=${formatMs(row.medianMs)} (n=${row.samples})`)
|
|
145
|
+
.join(" ");
|
|
146
|
+
lines.push(`typical step duration (median): ${durations || "(no finished attempts)"}`);
|
|
139
147
|
return lines.join("\n");
|
|
140
148
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Outbound notifications (iteration 08, C4): a Run that stops for a human
|
|
2
|
+
// should reach that human where they are, not wait for them to open a
|
|
3
|
+
// session and ask. Approval waits in the deploy campaign averaged 7–12 hours
|
|
4
|
+
// because nothing said "there is something for you".
|
|
5
|
+
//
|
|
6
|
+
// Discipline: fail-open (a notification failure never touches a run), secrets
|
|
7
|
+
// stay out of Git (the webhook URL comes from an env var named in the
|
|
8
|
+
// config), payloads carry identifiers and the next reply — never candidate
|
|
9
|
+
// content, never logs.
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
15
|
+
|
|
16
|
+
export const NOTIFY_CONFIG = join(".buildbeat", "notify.yaml");
|
|
17
|
+
export const NOTIFY_EVENTS = ["HUMAN_REQUESTED", "RUN_TERMINAL", "STALLED"];
|
|
18
|
+
const CHANNEL_TYPES = ["webhook", "dingtalk"];
|
|
19
|
+
|
|
20
|
+
export class NotifyConfigError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "NotifyConfigError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns null when no config exists. Throws NotifyConfigError on a config
|
|
28
|
+
// that exists but is malformed — callers decide whether that is fatal (it is
|
|
29
|
+
// not for a run; it is for `doctor`).
|
|
30
|
+
export function loadNotifyConfig(repoRoot) {
|
|
31
|
+
const path = join(repoRoot, NOTIFY_CONFIG);
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const doc = parseYamlSubset(readFileSync(path, "utf8"));
|
|
36
|
+
if (doc.kind !== "notify" || doc.version !== 1) {
|
|
37
|
+
throw new NotifyConfigError(`${NOTIFY_CONFIG}: expected kind: notify / version: 1`);
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(doc.channels) || doc.channels.length === 0) {
|
|
40
|
+
throw new NotifyConfigError(`${NOTIFY_CONFIG}: channels must be a non-empty list`);
|
|
41
|
+
}
|
|
42
|
+
const channels = doc.channels.map((channel, index) => {
|
|
43
|
+
const where = `${NOTIFY_CONFIG} channels[${index}]`;
|
|
44
|
+
if (typeof channel.id !== "string" || channel.id.length === 0) {
|
|
45
|
+
throw new NotifyConfigError(`${where}: id is required`);
|
|
46
|
+
}
|
|
47
|
+
if (!CHANNEL_TYPES.includes(channel.type)) {
|
|
48
|
+
throw new NotifyConfigError(`${where}: type must be one of ${CHANNEL_TYPES.join("|")}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof channel.urlEnv !== "string" || channel.urlEnv.length === 0) {
|
|
51
|
+
throw new NotifyConfigError(
|
|
52
|
+
`${where}: urlEnv is required (the webhook URL lives in that env var, never in Git)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (channel.url !== undefined) {
|
|
56
|
+
throw new NotifyConfigError(`${where}: url is not allowed in Git; use urlEnv`);
|
|
57
|
+
}
|
|
58
|
+
const events = channel.events ?? NOTIFY_EVENTS;
|
|
59
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
60
|
+
throw new NotifyConfigError(`${where}: events must be a non-empty list`);
|
|
61
|
+
}
|
|
62
|
+
for (const event of events) {
|
|
63
|
+
if (!NOTIFY_EVENTS.includes(event)) {
|
|
64
|
+
throw new NotifyConfigError(`${where}: unknown event ${event} (allowed: ${NOTIFY_EVENTS.join("|")})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { id: channel.id, type: channel.type, urlEnv: channel.urlEnv, events, keyword: channel.keyword ?? "BuildBeat" };
|
|
68
|
+
});
|
|
69
|
+
return { channels };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function subscribes(config, event) {
|
|
73
|
+
return Boolean(config?.channels.some((channel) => channel.events.includes(event)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The exact commands a human can copy to answer a pending request. Shared by
|
|
77
|
+
// status, inbox and notifications so "what do I say now" has one answer.
|
|
78
|
+
export function nextReply({ repoLabel, state }) {
|
|
79
|
+
const pending = state.pendingHuman;
|
|
80
|
+
if (!pending || !state.run) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const runId = state.run.id;
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (pending.kind === "finding-triage") {
|
|
86
|
+
lines.push(`buildbeat-v2 findings list --repo ${repoLabel} --work ${state.run.work}`);
|
|
87
|
+
lines.push(
|
|
88
|
+
`buildbeat-v2 findings adjudicate --repo ${repoLabel} --work ${state.run.work} --fingerprint <fp> --action accept|dismiss --by <you>`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
lines.push(
|
|
92
|
+
`buildbeat-v2 approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition} --by <you>` +
|
|
93
|
+
(pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: resume --config <run-config.yaml>"),
|
|
94
|
+
);
|
|
95
|
+
lines.push(`buildbeat-v2 reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildNotification(kind, { repoLabel, state, detail = {} }) {
|
|
100
|
+
const run = state.run;
|
|
101
|
+
const base = {
|
|
102
|
+
kind,
|
|
103
|
+
run: run?.id ?? null,
|
|
104
|
+
work: run?.work ?? null,
|
|
105
|
+
status: run?.status ?? null,
|
|
106
|
+
repo: repoLabel,
|
|
107
|
+
at: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
if (kind === "HUMAN_REQUESTED") {
|
|
110
|
+
const pending = state.pendingHuman;
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
title: `[BuildBeat] ${run.id} 等你拍板:${pending?.transition ?? "?"}`,
|
|
114
|
+
reasons: pending?.reasons ?? [],
|
|
115
|
+
candidate: pending?.subject?.candidate ?? null,
|
|
116
|
+
nextReply: nextReply({ repoLabel, state }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (kind === "RUN_TERMINAL") {
|
|
120
|
+
return {
|
|
121
|
+
...base,
|
|
122
|
+
title: `[BuildBeat] ${run.id} 结束:${state.terminal?.status ?? run.status}`,
|
|
123
|
+
reasons: state.terminal?.reason ? [state.terminal.reason] : [],
|
|
124
|
+
candidate: state.workspaces?.[run.id]?.candidate ?? null,
|
|
125
|
+
nextReply: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (kind === "STALLED") {
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
title: `[BuildBeat] ${run.id} 疑似卡住:${detail.step ?? "?"} 已 ${detail.sinceOutput ?? "?"} 无输出`,
|
|
132
|
+
reasons: [
|
|
133
|
+
`step ${detail.step ?? "?"} attempt ${detail.attempt ?? "?"} started ${detail.startedAt ?? "?"}, elapsed ${detail.elapsed ?? "?"}`,
|
|
134
|
+
detail.command ? `worker: ${detail.command}` : "worker: (unknown)",
|
|
135
|
+
`threshold ${detail.threshold ?? "?"}; the process is NOT killed — check status, then decide`,
|
|
136
|
+
],
|
|
137
|
+
candidate: null,
|
|
138
|
+
nextReply: [`buildbeat-v2 status --repo ${repoLabel} --run ${run.id}`],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
throw new NotifyConfigError(`unknown notification kind: ${kind}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderText(notification) {
|
|
145
|
+
const lines = [notification.title, `work: ${notification.work} status: ${notification.status}`];
|
|
146
|
+
for (const reason of notification.reasons ?? []) {
|
|
147
|
+
lines.push(`- ${reason}`);
|
|
148
|
+
}
|
|
149
|
+
if (notification.candidate) {
|
|
150
|
+
lines.push(`candidate: ${notification.candidate}`);
|
|
151
|
+
}
|
|
152
|
+
if (notification.nextReply?.length) {
|
|
153
|
+
lines.push("next:");
|
|
154
|
+
for (const line of notification.nextReply) {
|
|
155
|
+
lines.push(` ${line}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function payloadFor(channel, notification) {
|
|
162
|
+
if (channel.type === "dingtalk") {
|
|
163
|
+
// DingTalk custom robots require a configured keyword in the text; the
|
|
164
|
+
// title carries it (default "BuildBeat").
|
|
165
|
+
const text = renderText(notification);
|
|
166
|
+
return {
|
|
167
|
+
msgtype: "text",
|
|
168
|
+
text: { content: text.includes(channel.keyword) ? text : `${channel.keyword}\n${text}` },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { ...notification, text: renderText(notification) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function logLine(repoRoot, runId, line) {
|
|
175
|
+
if (!runId) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const dir = join(repoRoot, ".buildbeat", "runtime", "runs", runId);
|
|
180
|
+
mkdirSync(dir, { recursive: true });
|
|
181
|
+
appendFileSync(join(dir, "notify.log"), `${new Date().toISOString()} ${line}\n`, "utf8");
|
|
182
|
+
} catch {
|
|
183
|
+
// logging a notification must never fail the caller
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sends to every channel subscribed to the notification kind. Never throws;
|
|
188
|
+
// each channel reports ok/skipped/failed. A missing URL env var is a skip
|
|
189
|
+
// with a visible reason, not silence.
|
|
190
|
+
export async function dispatchNotification(config, notification, { repoRoot, fetchImpl = globalThis.fetch, timeoutMs = 8000, env = process.env } = {}) {
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const channel of config?.channels ?? []) {
|
|
193
|
+
if (!channel.events.includes(notification.kind)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const url = env[channel.urlEnv];
|
|
197
|
+
if (!url) {
|
|
198
|
+
results.push({ channel: channel.id, ok: false, skipped: true, error: `env ${channel.urlEnv} not set` });
|
|
199
|
+
logLine(repoRoot, notification.run, `${notification.kind} ${channel.id} SKIPPED env ${channel.urlEnv} not set`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetchImpl(url, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/json" },
|
|
208
|
+
body: JSON.stringify(payloadFor(channel, notification)),
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
});
|
|
211
|
+
const ok = response.ok;
|
|
212
|
+
results.push({ channel: channel.id, ok, status: response.status, error: ok ? null : `HTTP ${response.status}` });
|
|
213
|
+
logLine(repoRoot, notification.run, `${notification.kind} ${channel.id} ${ok ? "SENT" : "FAILED"} HTTP ${response.status}`);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = error?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : error?.message ?? String(error);
|
|
216
|
+
results.push({ channel: channel.id, ok: false, error: message });
|
|
217
|
+
logLine(repoRoot, notification.run, `${notification.kind} ${channel.id} FAILED ${message}`);
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// anything changed goes APPROVAL_STALE and back to WAITING_HUMAN.
|
|
12
12
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import { nextStep } from "../engine/workflow.js";
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
} from "../workspace/workspace-manager.js";
|
|
28
28
|
import { writeRunRecord } from "./run-record.js";
|
|
29
29
|
import { assertRequires } from "./env-contract.js";
|
|
30
|
+
import { materialisePrompt } from "./envelope.js";
|
|
31
|
+
import { cacheKey, findReusableEvidence, lastReviewedCandidate, treeHash } from "./cache.js";
|
|
30
32
|
import {
|
|
31
33
|
buildAnchor,
|
|
32
34
|
fingerprintFinding,
|
|
@@ -145,6 +147,10 @@ function makeContext(options, ledger, workspace) {
|
|
|
145
147
|
context.policies = options.policies ?? [];
|
|
146
148
|
context.allowedPaths = options.allowedPaths ?? null;
|
|
147
149
|
context.reviewTriage = options.reviewTriage ?? null;
|
|
150
|
+
context.envelope = options.envelope ?? null;
|
|
151
|
+
context.cache = options.cache ?? {};
|
|
152
|
+
context.redact = options.redact ?? [];
|
|
153
|
+
context.adapterConfigs = options.adapterConfigs ?? {};
|
|
148
154
|
context.policyCtx = () => ({
|
|
149
155
|
state: ledger.state,
|
|
150
156
|
candidate: ledger.state.workspaces[workspace.workspaceId]?.candidate ?? null,
|
|
@@ -379,14 +385,76 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
379
385
|
}));
|
|
380
386
|
}
|
|
381
387
|
}
|
|
382
|
-
|
|
383
|
-
|
|
388
|
+
// Envelope (C6): the worker's prompt, materialised into the run
|
|
389
|
+
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
390
|
+
const prompt = materialisePrompt({
|
|
391
|
+
envelope: context.envelope,
|
|
384
392
|
worker: stepDef.worker,
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
393
|
+
runtimeDir: context.runtimeDir,
|
|
394
|
+
runId: ledger.state.run.id,
|
|
395
|
+
step,
|
|
396
|
+
attempt,
|
|
397
|
+
repoRoot: context.repoRoot,
|
|
389
398
|
});
|
|
399
|
+
if (prompt) {
|
|
400
|
+
input.envelope = { promptRef: prompt.ref, file: prompt.file, digest: context.envelope.digest, vars: context.envelope.vars };
|
|
401
|
+
}
|
|
402
|
+
// Incremental review (C7): tell a reviewer which candidate the last
|
|
403
|
+
// review saw when it is an ancestor of this one.
|
|
404
|
+
if (stepDef.readonly) {
|
|
405
|
+
const head = before.head;
|
|
406
|
+
const lastReviewed = lastReviewedCandidate(context.repoRoot, ledger.state.run.work, workspace.worktreePath, head);
|
|
407
|
+
if (lastReviewed) {
|
|
408
|
+
input.lastReviewed = lastReviewed;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Verification reuse (C7): same tree + same worker + same envelope that
|
|
412
|
+
// already passed is referenced, not re-run. Failures always re-run.
|
|
413
|
+
let stepCacheKey = null;
|
|
414
|
+
let reused = null;
|
|
415
|
+
if (context.cache[step] === "tree") {
|
|
416
|
+
const current = readback(workspace.worktreePath);
|
|
417
|
+
if (!current.dirty) {
|
|
418
|
+
stepCacheKey = cacheKey({
|
|
419
|
+
tree: treeHash(workspace.worktreePath),
|
|
420
|
+
worker: stepDef.worker,
|
|
421
|
+
adapterSpec: context.adapterConfigs[stepDef.worker] ?? null,
|
|
422
|
+
adapterName: adapter.name,
|
|
423
|
+
envelopeDigest: context.envelope?.digest ?? null,
|
|
424
|
+
});
|
|
425
|
+
reused = findReusableEvidence(context.repoRoot, stepCacheKey);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
let exec;
|
|
429
|
+
if (reused) {
|
|
430
|
+
const at = now();
|
|
431
|
+
exec = {
|
|
432
|
+
adapter: "cache",
|
|
433
|
+
command: `reuse ${reused.run} ${reused.evidenceRef}`,
|
|
434
|
+
exitCode: 0,
|
|
435
|
+
signal: null,
|
|
436
|
+
stdout: `REUSED: identical tree/worker/envelope already passed in ${reused.run} (${reused.evidenceRef}, ${reused.digest}); not re-run`,
|
|
437
|
+
stderr: "",
|
|
438
|
+
timedOut: false,
|
|
439
|
+
spawnError: null,
|
|
440
|
+
startedAt: at,
|
|
441
|
+
finishedAt: at,
|
|
442
|
+
};
|
|
443
|
+
} else {
|
|
444
|
+
exec = adapter.execute({
|
|
445
|
+
step,
|
|
446
|
+
worker: stepDef.worker,
|
|
447
|
+
workspacePath: workspace.worktreePath,
|
|
448
|
+
input,
|
|
449
|
+
timeoutMs: context.stepTimeoutMs,
|
|
450
|
+
outputPath,
|
|
451
|
+
// Live output streams + marker land in the run directory so `status`
|
|
452
|
+
// can answer "is it still doing something" while the step runs.
|
|
453
|
+
liveDir: join(context.runtimeDir, "runs", ledger.state.run.id),
|
|
454
|
+
promptPath: prompt?.path ?? null,
|
|
455
|
+
vars: context.envelope?.vars ?? null,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
390
458
|
const tree = readback(workspace.worktreePath);
|
|
391
459
|
const evidence = collectCommandEvidence({
|
|
392
460
|
runtimeDir: context.runtimeDir,
|
|
@@ -395,6 +463,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
395
463
|
attempt,
|
|
396
464
|
execResult: exec,
|
|
397
465
|
subject: tree.head,
|
|
466
|
+
grade: reused ? reused.grade : stepDef.grade ?? "L2",
|
|
467
|
+
redact: context.redact,
|
|
398
468
|
});
|
|
399
469
|
ledger.append({
|
|
400
470
|
type: "EVIDENCE_RECORDED",
|
|
@@ -407,6 +477,8 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
407
477
|
digest: evidence.digest,
|
|
408
478
|
status: evidence.status,
|
|
409
479
|
grade: evidence.grade,
|
|
480
|
+
...(stepCacheKey ? { cacheKey: stepCacheKey } : {}),
|
|
481
|
+
...(reused ? { reused: { run: reused.run, evidenceRef: reused.evidenceRef, digest: reused.digest } } : {}),
|
|
410
482
|
},
|
|
411
483
|
});
|
|
412
484
|
|
|
@@ -617,6 +689,57 @@ function drive(context, startStep, { skipBoundaryOnce = false } = {}) {
|
|
|
617
689
|
}
|
|
618
690
|
}
|
|
619
691
|
|
|
692
|
+
// Supersede (iteration 08, C2): a new Run for the same Work makes any older
|
|
693
|
+
// Run still waiting on a human moot — the human would be approving a
|
|
694
|
+
// candidate nobody intends to merge. Real incident: two WAITING_HUMAN runs
|
|
695
|
+
// sat in a pilot repo's inbox for a day after their successor had already
|
|
696
|
+
// shipped. Only WAITING_HUMAN runs are touched; RUNNING ones are protected
|
|
697
|
+
// by the active lock, terminal ones are already settled.
|
|
698
|
+
function supersedeWaitingRuns(repoRoot, workId, newRunId, now) {
|
|
699
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
700
|
+
const superseded = [];
|
|
701
|
+
const skipped = [];
|
|
702
|
+
if (!existsSync(runsDir)) {
|
|
703
|
+
return { superseded, skipped };
|
|
704
|
+
}
|
|
705
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
706
|
+
if (entry === newRunId) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
710
|
+
if (!existsSync(ledgerPath)) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
714
|
+
const state = ledger.state;
|
|
715
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (state.terminal || state.run.status !== "WAITING_HUMAN") {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
acquireLock(repoRoot, entry);
|
|
723
|
+
} catch {
|
|
724
|
+
skipped.push({ run: entry, reason: "locked by another process" });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
ledger.append({
|
|
729
|
+
type: "RUN_TERMINAL",
|
|
730
|
+
actor: KERNEL,
|
|
731
|
+
ts: now(),
|
|
732
|
+
data: { status: "SUPERSEDED", reason: `superseded by ${newRunId} (same work ${workId})` },
|
|
733
|
+
});
|
|
734
|
+
writeRunRecord({ repoRoot, ledger, ts: now() });
|
|
735
|
+
superseded.push(entry);
|
|
736
|
+
} finally {
|
|
737
|
+
releaseLock(repoRoot, entry);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return { superseded, skipped };
|
|
741
|
+
}
|
|
742
|
+
|
|
620
743
|
function openLedgerFor(repoRoot, runId) {
|
|
621
744
|
const ledgerPath = join(repoRoot, ".buildbeat", "runtime", "runs", runId, "events.jsonl");
|
|
622
745
|
const ledger = EventLedger.open(ledgerPath);
|
|
@@ -664,6 +787,10 @@ export function startRun(options) {
|
|
|
664
787
|
const workspace = createWorkspace({ repoRoot, runId, base });
|
|
665
788
|
const context = makeContext(options, ledger, workspace);
|
|
666
789
|
const now = context.now;
|
|
790
|
+
const supersession =
|
|
791
|
+
options.supersede === "off"
|
|
792
|
+
? { superseded: [], skipped: [] }
|
|
793
|
+
: supersedeWaitingRuns(repoRoot, workId, runId, now);
|
|
667
794
|
ledger.append({
|
|
668
795
|
type: "RUN_CREATED",
|
|
669
796
|
actor: KERNEL,
|
|
@@ -678,6 +805,8 @@ export function startRun(options) {
|
|
|
678
805
|
entry,
|
|
679
806
|
planDigest: planDigest ?? "UNVERIFIED",
|
|
680
807
|
intentDigest: intentDigest ?? "UNVERIFIED",
|
|
808
|
+
...(supersession.superseded.length > 0 ? { supersedes: supersession.superseded } : {}),
|
|
809
|
+
...(options.envelope ? { envelopeDigest: options.envelope.digest, envelopeSource: options.envelope.source } : {}),
|
|
681
810
|
},
|
|
682
811
|
});
|
|
683
812
|
ledger.append({ type: "RUN_STARTED", actor: KERNEL, ts: now(), data: {} });
|
|
@@ -694,7 +823,15 @@ export function startRun(options) {
|
|
|
694
823
|
},
|
|
695
824
|
});
|
|
696
825
|
drive(context, entry);
|
|
697
|
-
return {
|
|
826
|
+
return {
|
|
827
|
+
runId,
|
|
828
|
+
workId,
|
|
829
|
+
ledgerPath,
|
|
830
|
+
state: ledger.state,
|
|
831
|
+
workspace,
|
|
832
|
+
superseded: supersession.superseded,
|
|
833
|
+
supersedeSkipped: supersession.skipped,
|
|
834
|
+
};
|
|
698
835
|
});
|
|
699
836
|
}
|
|
700
837
|
|