@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +29 -7
  2. package/SKILL.md +76 -2
  3. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  4. package/docs/CLI.md +1 -1
  5. package/docs/EXECUTION-PLAN.md +2 -2
  6. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  7. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  8. package/docs/RELEASING.md +1 -1
  9. package/docs/V2-D2-DECISION-CARD.md +2 -2
  10. package/docs/V2-DECISIONS.md +2 -2
  11. package/docs/V2-ITERATION-01.md +13 -13
  12. package/docs/V2-ITERATION-06.md +2 -2
  13. package/docs/V2-ITERATION-08.md +62 -0
  14. package/docs/V2-PLAN.md +6 -6
  15. package/docs/V2-PROPOSAL.md +2 -2
  16. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  17. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  19. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  20. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  21. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  22. package/docs/v2/RFC-0001-product-definition.md +2 -2
  23. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  24. package/docs/v2/guide/00-how-to-talk.md +57 -0
  25. package/docs/v2/guide/01-quickstart.md +4 -0
  26. package/docs/v2/guide/02-workflow-guide.md +14 -0
  27. package/docs/v2/guide/04-adapter-guide.md +4 -0
  28. package/docs/v2/guide/05-worker-contract.md +10 -0
  29. package/docs/v2/guide/06-evidence-guide.md +4 -0
  30. package/docs/v2/guide/07-approval-guide.md +33 -0
  31. package/docs/v2/guide/10-recovery.md +22 -1
  32. package/docs/v2/guide/README.md +3 -0
  33. package/example/.buildbeat/manifest.json +1 -1
  34. package/lessons.md +12 -0
  35. package/package.json +1 -1
  36. package/src/v2/adapters/shell.js +87 -14
  37. package/src/v2/cli/run.js +461 -25
  38. package/src/v2/engine/reducer.js +2 -0
  39. package/src/v2/engine/workflow.js +8 -1
  40. package/src/v2/evidence/collector.js +14 -3
  41. package/src/v2/presets/release-readback.yaml +36 -0
  42. package/src/v2/presets/risk/release.yaml +21 -0
  43. package/src/v2/runtime/cache.js +124 -0
  44. package/src/v2/runtime/env-contract.js +35 -1
  45. package/src/v2/runtime/envelope.js +183 -0
  46. package/src/v2/runtime/gc.js +182 -0
  47. package/src/v2/runtime/liveness.js +193 -0
  48. package/src/v2/runtime/metrics.js +8 -0
  49. package/src/v2/runtime/notify.js +223 -0
  50. package/src/v2/runtime/orchestrator.js +145 -8
  51. package/src/v2/runtime/overview.js +264 -0
  52. package/templates/v2/AGENTS.md +72 -0
  53. 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
- const exec = adapter.execute({
383
- step,
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
- workspacePath: workspace.worktreePath,
386
- input,
387
- timeoutMs: context.stepTimeoutMs,
388
- outputPath,
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 { runId, workId, ledgerPath, state: ledger.state, workspace };
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