@haiyangbg/buildbeat 2.0.0-beta.2 → 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 (62) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/SKILL.md +76 -2
  3. package/bin/buildbeat-v2.js +13 -1
  4. package/docs/CLI-PILOT-2026-08-23.md +1 -1
  5. package/docs/CLI.md +1 -1
  6. package/docs/EXECUTION-PLAN.md +2 -2
  7. package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
  8. package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
  9. package/docs/RELEASING.md +1 -1
  10. package/docs/V2-D2-DECISION-CARD.md +2 -2
  11. package/docs/V2-DECISIONS.md +2 -2
  12. package/docs/V2-ITERATION-01.md +13 -13
  13. package/docs/V2-ITERATION-06.md +2 -2
  14. package/docs/V2-ITERATION-08.md +62 -0
  15. package/docs/V2-PLAN.md +6 -6
  16. package/docs/V2-PROPOSAL.md +2 -2
  17. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
  18. package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
  19. package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
  20. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
  21. package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
  22. package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
  23. package/docs/v2/RFC-0001-product-definition.md +2 -2
  24. package/docs/v2/SPEC-0001-events-v1.md +2 -2
  25. package/docs/v2/guide/00-how-to-talk.md +57 -0
  26. package/docs/v2/guide/01-quickstart.md +6 -0
  27. package/docs/v2/guide/02-workflow-guide.md +31 -0
  28. package/docs/v2/guide/04-adapter-guide.md +4 -0
  29. package/docs/v2/guide/05-worker-contract.md +10 -0
  30. package/docs/v2/guide/06-evidence-guide.md +10 -0
  31. package/docs/v2/guide/07-approval-guide.md +50 -0
  32. package/docs/v2/guide/10-recovery.md +25 -2
  33. package/docs/v2/guide/README.md +3 -0
  34. package/example/.buildbeat/manifest.json +1 -1
  35. package/lessons.md +12 -0
  36. package/package.json +1 -1
  37. package/src/v2/adapters/shell.js +87 -14
  38. package/src/v2/cli/run.js +602 -30
  39. package/src/v2/domain/event-registry.js +24 -0
  40. package/src/v2/engine/reducer.js +5 -0
  41. package/src/v2/engine/workflow.js +8 -1
  42. package/src/v2/evidence/collector.js +14 -3
  43. package/src/v2/observe/observe.js +9 -2
  44. package/src/v2/presets/release-readback.yaml +36 -0
  45. package/src/v2/presets/risk/release.yaml +21 -0
  46. package/src/v2/presets/software-delivery.yaml +5 -0
  47. package/src/v2/runtime/cache.js +124 -0
  48. package/src/v2/runtime/decisions.js +6 -4
  49. package/src/v2/runtime/env-contract.js +135 -0
  50. package/src/v2/runtime/envelope.js +183 -0
  51. package/src/v2/runtime/findings.js +158 -0
  52. package/src/v2/runtime/gc.js +182 -0
  53. package/src/v2/runtime/liveness.js +193 -0
  54. package/src/v2/runtime/metrics.js +8 -0
  55. package/src/v2/runtime/notify.js +223 -0
  56. package/src/v2/runtime/orchestrator.js +252 -24
  57. package/src/v2/runtime/overview.js +264 -0
  58. package/src/v2/runtime/repo-ref.js +38 -0
  59. package/src/v2/runtime/run-record.js +18 -3
  60. package/src/v2/workspace/workspace-manager.js +4 -1
  61. package/templates/v2/AGENTS.md +72 -0
  62. 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
+ }