omp-conductor 0.3.24 → 0.4.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/src/reports.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The report outbox: the half of a tick report the model does not own.
3
+ *
4
+ * Authorship stays with the model — rendering a material report needs judgement
5
+ * the daemon does not have. Delivery does not. On 2026-08-06 a suite release and
6
+ * two tier-2 escalations were written and never sent, because a report reached
7
+ * the operator only if the model remembered to call `telegram_send`, and an
8
+ * unsent report was indistinguishable from a quiet tick (#123). `TICK_DELIVERY_RULE`
9
+ * made that an instruction; this module makes it a mechanism.
10
+ *
11
+ * Three rules shape everything here:
12
+ *
13
+ * 1. **The row exists before the request does.** A report is persisted
14
+ * `pending` when it is handed over, and moved to `sending` with the id of
15
+ * the attempt about to run *before* the fetch. A crash therefore leaves an
16
+ * explicitly ambiguous row, not a silently lost one.
17
+ * 2. **Delivery is at-least-once, and says so.** The Bot API accepts no
18
+ * client-supplied idempotency key and offers no bot-readable record of what
19
+ * it already sent, so "exactly once" cannot be built on this transport —
20
+ * `escalate.ts:258-266` is the incident from guessing the other way, where a
21
+ * delivered page was read as rejected and re-sent every tick. A recovered
22
+ * `sending` row is retried, and its message says it may be a repeat.
23
+ * 3. **Not every throw means the same thing.** {@link sendTelegram} classifies
24
+ * its own failures, because only the code watching the socket can. A
25
+ * *definitive* failure — Telegram refused it, or the connection never
26
+ * opened — proves nobody has the message, so the row goes back to
27
+ * `pending` and the retry is an ordinary first attempt. An *unknown*
28
+ * outcome — cut off after the request left, or a 200 whose body could not
29
+ * be read — proves nothing, so the row stays `sending` and is flagged: the
30
+ * retry says it may be a repeat. Anything unclassifiable counts as unknown.
31
+ * Collapsing the two is the tempting simplification and the wrong one; it
32
+ * re-posts a delivered report as though it were new.
33
+ *
34
+ * The retry posture is `escalate.ts:151-160` generalised, not reinvented: the
35
+ * ledger row is only advanced by an outcome, so an attempt that reaches no
36
+ * conclusion leaves something for the next pass to pick up.
37
+ */
38
+
39
+ import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
+ import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
41
+
42
+ /**
43
+ * Attempts before a report is written off. Six attempts across the backoff
44
+ * below is a little over half an hour — long enough to ride out a Telegram
45
+ * blip or a router reboot, short enough that an operator learns their reporting
46
+ * channel is broken while the day it broke is still today.
47
+ */
48
+ export const REPORT_MAX_ATTEMPTS = 6;
49
+
50
+ /** Ceiling on the retry gap. Past this, waiting longer buys nothing: whatever
51
+ * is broken needs a human, and `status` plus the tier-2 page are how they hear. */
52
+ const REPORT_BACKOFF_CAP_MS = 15 * 60_000;
53
+
54
+ const REPORT_BACKOFF_BASE_MS = 30_000;
55
+
56
+ /**
57
+ * How long a `sending` row may sit untouched before a pass reclaims it. A
58
+ * process that died has no timer left to resolve its own row, and neither does
59
+ * a `fetch` that never came back — the attempt id on every terminal transition
60
+ * is what makes reclaiming safe, because a late answer from the reclaimed
61
+ * attempt no longer matches the row and is discarded rather than applied.
62
+ */
63
+ const SENDING_STALE_MS = 5 * 60_000;
64
+
65
+ /** Reports sent per pass. Bounded so a backlog cannot hold the pass — and the
66
+ * daemon's shutdown drain — open for an unpredictable stretch. */
67
+ const REPORT_BATCH = 5;
68
+
69
+ /** Fleet-scoped pages carry issue `0`; a report has no tracker issue at all. */
70
+ const NO_ISSUE = 0;
71
+
72
+ /** Bound on failure text in the operator-facing status block. */
73
+ const ERROR_SAMPLE = 90;
74
+
75
+ /**
76
+ * One delivery attempt's transport. Injected so the delivery contract — claim,
77
+ * send, record, retry — is testable without a live bot, which is the same split
78
+ * `confineToolCall` and `verifyPr` use: the decision is separable from the I/O.
79
+ * Resolves with Telegram's own message id when it returns one, throws on any
80
+ * known failure.
81
+ */
82
+ export type ReportSend = (text: string) => Promise<number | undefined>;
83
+
84
+ /** What one pass did, by report id. Returned for the daemon's log and the tests. */
85
+ export interface ReportDeliveryPass {
86
+ delivered: string[];
87
+ /** Known-failed, back in `pending` behind a backoff. */
88
+ requeued: string[];
89
+ /** The attempt ended without an answer. Left `sending` and flagged as a
90
+ * possible repeat; stale recovery hands it back when the window closes. */
91
+ uncertain: string[];
92
+ /** Out of attempts. Terminal, and escalated as tier 2 in its own right. */
93
+ failed: string[];
94
+ /** Reclaimed from `sending`: their outcome was never learned. */
95
+ recovered: string[];
96
+ }
97
+
98
+ export interface ReportOutbox {
99
+ /**
100
+ * Return `sending` rows last touched at or before `staleAt` to `pending`,
101
+ * flagged ambiguous. Called at daemon startup with "now" — every row still
102
+ * `sending` when a daemon boots belonged to a process that is gone.
103
+ */
104
+ recover(staleAt: number): ReportRecord[];
105
+ /** One bounded pass: reclaim, then send what is due, oldest first. */
106
+ deliverDue(): Promise<ReportDeliveryPass>;
107
+ }
108
+
109
+ export interface ReportOutboxDeps {
110
+ project: ProjectConfig;
111
+ store: Store;
112
+ /** A report nobody can deliver escalates through this. Optional so a unit
113
+ * test can exercise delivery without wiring an escalator. */
114
+ escalate?: (e: Escalation) => Promise<void>;
115
+ send?: ReportSend;
116
+ now?: () => number;
117
+ log?: (msg: string) => void;
118
+ }
119
+
120
+ /**
121
+ * Local day, matching how a human reads "one daily digest" and how the
122
+ * dispatcher's own `startOfToday` reads "today". A UTC key would roll the
123
+ * digest over mid-evening for anyone west of Greenwich.
124
+ */
125
+ export function digestDedupeKey(at: number): string {
126
+ const d = new Date(at);
127
+ const month = `${d.getMonth() + 1}`.padStart(2, "0");
128
+ const day = `${d.getDate()}`.padStart(2, "0");
129
+ return `digest:${d.getFullYear()}-${month}-${day}`;
130
+ }
131
+
132
+ /** Exponential, capped. `attempts` is attempts *started*, so the first failure
133
+ * waits one base interval rather than none. */
134
+ export function reportBackoffMs(attempts: number): number {
135
+ const exponent = Math.max(0, attempts - 1);
136
+ if (exponent >= 31) return REPORT_BACKOFF_CAP_MS;
137
+ return Math.min(REPORT_BACKOFF_BASE_MS * 2 ** exponent, REPORT_BACKOFF_CAP_MS);
138
+ }
139
+
140
+ /**
141
+ * What actually lands in the chat.
142
+ *
143
+ * Plain text, no `parse_mode`, for the same reason {@link formatEscalation} is:
144
+ * an underscore in a repo name would otherwise make Telegram reject the whole
145
+ * send and turn a cosmetic problem into a lost report.
146
+ *
147
+ * The report id leads because it is the only thing this transport gives an
148
+ * operator to recognise a duplicate by. When the row is ambiguous the message
149
+ * says so in words rather than a symbol — the person reading it at 03:00 needs
150
+ * to know whether to act twice, and "(2)" does not tell them that.
151
+ */
152
+ export function formatReportMessage(r: ReportRecord, project: string): string {
153
+ const lines = [
154
+ `omp-conductor · report ${r.id}${r.ambiguous ? " (POSSIBLE REPEAT)" : ""}`,
155
+ `project: ${project}`,
156
+ ];
157
+ if (r.ambiguous) {
158
+ lines.push(
159
+ `An earlier attempt at this exact report left this host and never came back with an ` +
160
+ `answer, so it may already be in this chat. Delivery is at-least-once: check for ` +
161
+ `report ${r.id} above and ignore whichever copy is the duplicate.`,
162
+ );
163
+ }
164
+ lines.push("", r.body);
165
+ return lines.join("\n");
166
+ }
167
+
168
+ /**
169
+ * The `reports` block in `status`.
170
+ *
171
+ * `pending` and `sending` are printed differently on purpose: they ask for
172
+ * different things. A pending report is one nobody has — the daemon will retry
173
+ * it, and if that keeps failing the error text says why. A sending report is one
174
+ * whose outcome was never learned, so the operator may already have it, and the
175
+ * retry that resolves it will arrive marked as a possible repeat. Collapsing the
176
+ * two into "not delivered" would lose exactly the distinction that decides
177
+ * whether they go and look in the chat.
178
+ */
179
+ export function formatOpenReports(
180
+ reports: readonly ReportRecord[],
181
+ now: number = Date.now(),
182
+ ): string[] {
183
+ if (reports.length === 0) return [];
184
+ const counts = { pending: 0, sending: 0, failed: 0 };
185
+ for (const r of reports) {
186
+ if (r.state === "pending") counts.pending += 1;
187
+ else if (r.state === "sending") counts.sending += 1;
188
+ else if (r.state === "failed") counts.failed += 1;
189
+ }
190
+ const lines = [
191
+ "",
192
+ `reports ${counts.pending} pending · ${counts.sending} sending · ${counts.failed} failed ` +
193
+ `(delivery is at-least-once — a retry may duplicate)`,
194
+ ];
195
+ for (const r of reports) {
196
+ const head =
197
+ ` ${r.id} ${r.state === "pending" ? "pending" : r.state.toUpperCase()}`.padEnd(24) +
198
+ ` ${r.kind.padEnd(8)} ${humanAge(now - r.createdAt)} old`;
199
+ lines.push(`${head} ${openReportDetail(r, now)}`);
200
+ }
201
+ return lines;
202
+ }
203
+
204
+ function openReportDetail(r: ReportRecord, now: number): string {
205
+ const flat = r.lastError?.replace(/\s+/g, " ").trim() ?? "";
206
+ const error =
207
+ flat.length === 0 ? "" : ` (${flat.length > ERROR_SAMPLE ? `${flat.slice(0, ERROR_SAMPLE)}…` : flat})`;
208
+ if (r.state === "failed") {
209
+ return `gave up after ${r.attempts} attempts — undeliverable${error}`;
210
+ }
211
+ if (r.state === "sending") {
212
+ return (
213
+ `attempt ${r.attempts}, outcome unknown — it left this host and never came back ` +
214
+ `with an answer, so Telegram may already have it; the retry will say it may be a ` +
215
+ `repeat${error}`
216
+ );
217
+ }
218
+ const wait = r.nextAttemptAt <= now ? "due now" : `retry in ${humanAge(r.nextAttemptAt - now)}`;
219
+ const repeat = r.ambiguous ? ", may be a repeat" : "";
220
+ return `attempt ${r.attempts}/${REPORT_MAX_ATTEMPTS}, ${wait}${repeat}${error}`;
221
+ }
222
+
223
+ function humanAge(ms: number): string {
224
+ if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1_000))}s`;
225
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
226
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
227
+ return `${Math.round(ms / 86_400_000)}d`;
228
+ }
229
+
230
+ /**
231
+ * The default transport: the same bot token and the same `sendMessage` a tier-2
232
+ * page uses. A missing chat id or token is treated as a known failure rather
233
+ * than a permanent one, because both are fixable while the daemon runs — the
234
+ * operator sets `escalation.telegramChatId`, or installs omp-telegram, and the
235
+ * next retry lands. Burning the attempt budget is how they find out: `status`
236
+ * names the reason on the row, and the exhausted report pages tier 2.
237
+ */
238
+ export function telegramReportSend(p: ProjectConfig): ReportSend {
239
+ return async (text: string): Promise<number | undefined> => {
240
+ // Both of these are `definitive` because nothing left the host: there is no
241
+ // message anywhere, so the retry is a first attempt and must not be
242
+ // announced to the operator as a possible duplicate.
243
+ const chatId = p.escalation.telegramChatId;
244
+ if (chatId === undefined || chatId === "") {
245
+ throw new TelegramSendError(
246
+ `no report transport for project "${p.name}": set escalation.telegramChatId`,
247
+ "definitive",
248
+ );
249
+ }
250
+ const token = readTelegramToken();
251
+ if (token === undefined) {
252
+ throw new TelegramSendError(
253
+ "no Telegram bot token readable — install and configure omp-telegram, or set OMP_TELEGRAM_STATE_DIR",
254
+ "definitive",
255
+ );
256
+ }
257
+ return await sendTelegram(token, chatId, text);
258
+ };
259
+ }
260
+
261
+ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
262
+ const { project, store } = deps;
263
+ const now = deps.now ?? Date.now;
264
+ const send = deps.send ?? telegramReportSend(project);
265
+ const log = deps.log ?? ((): void => {});
266
+
267
+ /**
268
+ * A report that exhausted its retries is itself news, so it pages tier 2 —
269
+ * the explicit answer to the question #123 left open. The obvious objection is
270
+ * that this page rides the transport that just failed six times, and that is
271
+ * true and accepted: tier 2 has an issue-comment fallback only for
272
+ * issue-scoped events, and a report has no issue, so a total Telegram outage
273
+ * degrades this to a log line and the `reports` block in `status`. It is still
274
+ * worth sending. The common failure is not "Telegram is down" but "this chat
275
+ * id is wrong" or "the bot was kicked from this chat", and in both of those
276
+ * the page reaches an operator who is otherwise being told nothing at all.
277
+ *
278
+ * It goes through the ordinary escalator, so the `notifications` ledger
279
+ * deduplicates it: the summary carries the report id, which is unique, so one
280
+ * undeliverable report pages exactly once and never every five minutes.
281
+ */
282
+ const pageUndeliverable = async (r: ReportRecord, error: string): Promise<void> => {
283
+ if (deps.escalate === undefined) return;
284
+ try {
285
+ await deps.escalate({
286
+ tier: 2,
287
+ project: project.name,
288
+ issue: NO_ISSUE,
289
+ summary: `Report ${r.id} could not be delivered after ${r.attempts} attempts — ${project.name} is reporting into a void`,
290
+ detail: [
291
+ `Kind: ${r.kind}. Handed over ${new Date(r.createdAt).toISOString()}.`,
292
+ `Last error: ${error}`,
293
+ "",
294
+ "The report itself is still in the outbox and is shown by `omp-conductor status`.",
295
+ "Nothing retries it again: fix the transport, then treat the status block as the backlog.",
296
+ "",
297
+ r.body,
298
+ ].join("\n"),
299
+ });
300
+ } catch (err) {
301
+ log(
302
+ `report ${r.id} is undeliverable and its tier-2 page failed too ` +
303
+ `(${err instanceof Error ? err.message : String(err)}) — ` +
304
+ `it stays in \`status\` as the only surface left`,
305
+ );
306
+ }
307
+ };
308
+
309
+ const giveUp = async (
310
+ claimed: ReportRecord,
311
+ attemptId: string,
312
+ error: string,
313
+ pass: ReportDeliveryPass,
314
+ ): Promise<void> => {
315
+ store.markReportFailed(claimed.id, attemptId, error, now());
316
+ pass.failed.push(claimed.id);
317
+ log(`report ${claimed.id} failed after ${claimed.attempts} attempts: ${error}`);
318
+ await pageUndeliverable({ ...claimed, state: "failed" }, error);
319
+ };
320
+
321
+ const attempt = async (r: ReportRecord, pass: ReportDeliveryPass): Promise<void> => {
322
+ const attemptId = crypto.randomUUID();
323
+ // Losing this race is ordinary: another pass, or another daemon, already
324
+ // owns the attempt. Returning is what keeps one report from being in flight
325
+ // twice at once — the achievable half of "never double-posts", and the half
326
+ // that actually prevents a retry storm.
327
+ const claimed = store.claimReport(r.id, attemptId, now());
328
+ if (claimed === undefined) return;
329
+
330
+ // A report whose attempts all ended *unknown* never produces a definitive
331
+ // failure to trip the budget below, so without this it would cycle through
332
+ // stale recovery forever, posting a possible duplicate every window. A
333
+ // budget nothing can exhaust is not a budget.
334
+ if (claimed.attempts > REPORT_MAX_ATTEMPTS) {
335
+ await giveUp(
336
+ claimed,
337
+ attemptId,
338
+ claimed.lastError ?? "every attempt ended without an answer from Telegram",
339
+ pass,
340
+ );
341
+ return;
342
+ }
343
+
344
+ let messageId: number | undefined;
345
+ try {
346
+ messageId = await send(formatReportMessage(claimed, project.name));
347
+ } catch (err) {
348
+ const at = now();
349
+ const error = err instanceof Error ? err.message : String(err);
350
+
351
+ /**
352
+ * Which of the two failures this was is decided at the transport, by the
353
+ * code that watched the socket — never here, and never by inspecting the
354
+ * message text. {@link sendTelegram} throws `TelegramSendError` carrying
355
+ * the verdict; anything else reaching this catch is a `ReportSend` that
356
+ * did not classify, and is treated as `unknown` on purpose.
357
+ *
358
+ * Do not "simplify" this into the requeue branch below. Every throw is
359
+ * not a known failure: an aborted request, a socket reset, or a body read
360
+ * that failed after a 200 all leave Telegram possibly holding the
361
+ * message. Requeuing those as `pending` would drop the possible-repeat
362
+ * marker and post a second copy of a delivered report with nothing
363
+ * anywhere saying it might be one — the precise dishonesty #123's
364
+ * correction exists to forbid.
365
+ */
366
+ const outcome = err instanceof TelegramSendError ? err.outcome : "unknown";
367
+ if (outcome === "unknown") {
368
+ // Stays `sending`: the row's own state is the honest record that nobody
369
+ // knows. Stale recovery hands it back when the window closes, and every
370
+ // message from then on says it may be a repeat.
371
+ store.markReportUncertain(claimed.id, attemptId, error);
372
+ pass.uncertain.push(claimed.id);
373
+ log(
374
+ `report ${claimed.id} attempt ${claimed.attempts} left this host and never came back ` +
375
+ `with an answer (${error}) — held as sending; the retry will say it may be a repeat`,
376
+ );
377
+ return;
378
+ }
379
+
380
+ if (claimed.attempts >= REPORT_MAX_ATTEMPTS) {
381
+ await giveUp(claimed, attemptId, error, pass);
382
+ return;
383
+ }
384
+ const delay = reportBackoffMs(claimed.attempts);
385
+ store.markReportPending(claimed.id, attemptId, at + delay, error, at);
386
+ pass.requeued.push(claimed.id);
387
+ log(
388
+ `report ${claimed.id} attempt ${claimed.attempts}/${REPORT_MAX_ATTEMPTS} was rejected, ` +
389
+ `retrying in ${Math.round(delay / 1_000)}s: ${error}`,
390
+ );
391
+ return;
392
+ }
393
+
394
+ store.markReportDelivered(claimed.id, attemptId, messageId, now());
395
+ pass.delivered.push(claimed.id);
396
+ log(
397
+ `report ${claimed.id} delivered${messageId === undefined ? "" : ` as telegram message ${messageId}`}` +
398
+ `${claimed.ambiguous ? " (marked as a possible repeat)" : ""}`,
399
+ );
400
+ };
401
+
402
+ return {
403
+ recover(staleAt: number): ReportRecord[] {
404
+ return store.recoverSendingReports(project.name, staleAt, now());
405
+ },
406
+
407
+ async deliverDue(): Promise<ReportDeliveryPass> {
408
+ const pass: ReportDeliveryPass = {
409
+ delivered: [],
410
+ requeued: [],
411
+ uncertain: [],
412
+ failed: [],
413
+ recovered: [],
414
+ };
415
+
416
+ // Before picking up new work: a row left `sending` past the stale window
417
+ // belongs to a process that is gone, or to a request that will never
418
+ // answer. Either way nobody is going to resolve it but us.
419
+ for (const recovered of store.recoverSendingReports(
420
+ project.name,
421
+ now() - SENDING_STALE_MS,
422
+ now(),
423
+ )) {
424
+ pass.recovered.push(recovered.id);
425
+ log(
426
+ `report ${recovered.id} was left mid-send and its outcome is unknown — ` +
427
+ `retrying; the message will say it may be a repeat`,
428
+ );
429
+ }
430
+
431
+ // Sequential, not parallel. Telegram rate-limits a bot per chat, and the
432
+ // operator reads these in the order they arrive — a burst that lands out
433
+ // of order costs more to read than it saves to send.
434
+ for (const r of store.dueReports(project.name, now(), REPORT_BATCH)) {
435
+ await attempt(r, pass);
436
+ }
437
+ return pass;
438
+ },
439
+ };
440
+ }