omp-conductor 0.3.25 → 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/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
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
|
+
}
|