omp-conductor 0.15.12 → 0.16.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.
Files changed (55) hide show
  1. package/REFERENCE.md +81 -6
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +6 -0
  4. package/src/admission.ts +745 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +93 -54
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +66 -34
  17. package/src/commands/unfreeze.ts +56 -0
  18. package/src/commands/watch.ts +77 -0
  19. package/src/config-schema.ts +9 -0
  20. package/src/config.ts +24 -0
  21. package/src/daemon.ts +485 -577
  22. package/src/dashboard/server.ts +2 -1
  23. package/src/decisions.ts +32 -7
  24. package/src/depends-on.ts +73 -0
  25. package/src/doctor.ts +418 -8
  26. package/src/escalate.ts +122 -15
  27. package/src/failure-class.ts +47 -0
  28. package/src/fleet.ts +55 -377
  29. package/src/gitops.ts +86 -1
  30. package/src/lifecycle.ts +113 -2
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +63 -0
  35. package/src/orchestrator-down.ts +231 -0
  36. package/src/orchestrator-tick.ts +14 -1
  37. package/src/orchestrator.ts +14 -0
  38. package/src/release-policy.ts +163 -18
  39. package/src/reports.ts +124 -12
  40. package/src/session-host.ts +6 -0
  41. package/src/setup-host.ts +386 -17
  42. package/src/setup-install.ts +40 -2
  43. package/src/setup-wizard.ts +314 -113
  44. package/src/setup.ts +58 -1
  45. package/src/status-render.ts +445 -0
  46. package/src/stop-provenance.ts +119 -0
  47. package/src/store.ts +533 -11
  48. package/src/types.ts +298 -4
  49. package/src/unblock.ts +1 -1
  50. package/src/upgrade-verify.ts +1 -1
  51. package/src/upgrade.ts +27 -8
  52. package/src/verbs/protocol.ts +16 -3
  53. package/src/verbs/server.ts +52 -1
  54. package/src/wizard-ui.ts +261 -46
  55. package/src/worker.ts +183 -10
package/src/escalate.ts CHANGED
@@ -29,8 +29,79 @@ import { heldNoticeId } from "./notices.ts";
29
29
  import type { OrchestratorHandle } from "./orchestrator.ts";
30
30
  import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
31
31
 
32
- /** Telegram rejects `sendMessage` over 4096 chars; leave room for the marker. */
33
- const TELEGRAM_TEXT_LIMIT = 4000;
32
+ /**
33
+ * Telegram rejects `sendMessage` over 4096 characters. Longer text is split
34
+ * into labelled parts ({@link telegramTextParts}) instead of truncated, so the
35
+ * tail of a digest or a tier-2 escalation arrives instead of being silently
36
+ * dropped (#566).
37
+ */
38
+ export const TELEGRAM_TEXT_LIMIT = 4096;
39
+
40
+ /**
41
+ * Width reserved for the `(i/n)` label prepended to every part of a split
42
+ * message, ported from omp-telegram's `PART_LABEL_RESERVE`. Parts are re-split
43
+ * against `TELEGRAM_TEXT_LIMIT - TELEGRAM_PART_LABEL_RESERVE` so the label can
44
+ * never push a part past the wire limit.
45
+ */
46
+ export const TELEGRAM_PART_LABEL_RESERVE = 16;
47
+
48
+ /**
49
+ * Split text into Telegram-sized parts. Ports omp-telegram 0.12.1's
50
+ * `chunkLabeled` semantics (the plugin this transport bypasses, #566) so a
51
+ * digest copies the same readability contract that plugin ships:
52
+ *
53
+ * - newline-preferred boundaries: a cut prefers the last paragraph break
54
+ * (`\n\n`), then line break, then the last space, each only when it lands in
55
+ * the second half of the window so parts stay large; an unsplittable run is
56
+ * hard-cut rather than dropped.
57
+ * - numbered labels: when a message takes more than one part, every part is
58
+ * prefixed `(i/N)\n` so a reader sees the message continues and knows how
59
+ * many parts to expect. Under the label budget, so no part exceeds the limit.
60
+ * - lossless: a split message reassembles byte-for-byte to its input
61
+ * (strip the labels and concatenate). Deliberately *not* ported from
62
+ * upstream: fence repair and leading-newline stripping are rendering
63
+ * flourishes for markdown-mode sends, and this transport sends plain text
64
+ * (`parse_mode` is never set) — both would rewrite bytes the operator never
65
+ * wrote.
66
+ *
67
+ * Text at or under the limit is returned whole and untouched, so the
68
+ * single-message path keeps sending byte-identical payloads.
69
+ */
70
+ export function telegramTextParts(text: string, limit: number = TELEGRAM_TEXT_LIMIT): string[] {
71
+ if (text.length === 0 || text.length <= limit) return [text];
72
+ // `splitToLimit` returns at least two pieces here (text is over the limit);
73
+ // the guard is for future-proofing against a limit rewrite.
74
+ const parts = splitToLimit(text, Math.max(1, limit));
75
+ if (parts.length === 1) return parts;
76
+ // Re-split under the label budget so `(i/N)\n` never pushes a part past the
77
+ // wire limit.
78
+ const labelled = splitToLimit(text, Math.max(1, limit - TELEGRAM_PART_LABEL_RESERVE));
79
+ return labelled.map((part, index) => `(${index + 1}/${labelled.length})\n${part}`);
80
+ }
81
+
82
+ /**
83
+ * The base splitter, ported from omp-telegram's `splitToLimit`. Prefers a
84
+ * paragraph break, then a line break, then a space — each only when it sits in
85
+ * the second half of the window, so a digest splits on natural boundaries
86
+ * rather than mid-word. Never drops a byte: the fallback is a hard cut at
87
+ * `limit`, which split a run with no whitespace in the window in the middle,
88
+ * and the parts still reassemble to the input.
89
+ */
90
+ function splitToLimit(text: string, limit: number): string[] {
91
+ if (text.length <= limit) return [text];
92
+ const out: string[] = [];
93
+ let rest = text;
94
+ while (rest.length > limit) {
95
+ const para = rest.lastIndexOf("\n\n", limit);
96
+ const line = rest.lastIndexOf("\n", limit);
97
+ const space = rest.lastIndexOf(" ", limit);
98
+ const cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit;
99
+ out.push(rest.slice(0, cut));
100
+ rest = rest.slice(cut);
101
+ }
102
+ out.push(rest);
103
+ return out;
104
+ }
34
105
 
35
106
  /**
36
107
  * Whether a failed send settles the question of delivery. This distinction is
@@ -144,6 +215,7 @@ export function createEscalator(
144
215
  orchestrator?: OrchestratorHandle,
145
216
  now: () => number = Date.now,
146
217
  deliveryAllowed: () => boolean = () => true,
218
+ onTier1Diverted?: (e: Escalation) => void,
147
219
  ): Escalator {
148
220
  const currentProject = typeof source === "function" ? source : (): ProjectConfig => source;
149
221
  return {
@@ -278,6 +350,13 @@ export function createEscalator(
278
350
  // Tier 1 lands here with no orchestrator, or with one that would not take
279
351
  // the injection; tier 2 lands here when omp-telegram is not installed or
280
352
  // the project never configured a chat id.
353
+ //
354
+ // A tier-1 event reaching this point was diverted from the orchestrator to
355
+ // an issue comment. That is the exact condition the down incident counts:
356
+ // the daemon hooks this to accumulate the orchestrator-down incident's
357
+ // diverted tally (a reader that owns its own dedupe and no-ops for a
358
+ // healthy or external orchestrator).
359
+ if (e.tier === 1) onTier1Diverted?.(e);
281
360
  if (!p.escalation.fallbackToIssueComment) {
282
361
  throw new Error(
283
362
  `no escalation transport configured for project "${p.name}": tier ${e.tier} ` +
@@ -463,26 +542,39 @@ export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
463
542
  * The one Telegram send in this package. Exported so the report outbox (#123)
464
543
  * reuses it rather than forking it: the response handling below is load-bearing
465
544
  * and was paid for once already (see the comment on the parse). Resolves with
466
- * Telegram's own message id when it returns one, and throws on every *known*
467
- * failure connection refused, HTTP error, `{"ok":false}` which is what lets
468
- * a caller treat a throw as "nobody has this" and a crash as "nobody knows".
545
+ * Telegram's own message id for every accepted part, in order one id for a
546
+ * message under the limit, one per message once a split is needed. Throws on
547
+ * every *known* failure connection refused, HTTP error, `{"ok":false}`
548
+ * which is what lets a caller treat a throw as "nobody has this" and a crash
549
+ * as "nobody knows".
550
+ *
551
+ * Splitting happens here, at the transport, so every message class that rides
552
+ * this seam — tier-2 escalations, report parts, fleet pages, operator
553
+ * messages — is delivered whole: a report's tail is the fleet state and open
554
+ * items, and a tier-2 escalation's tail may be the question itself (#566).
555
+ * Parts are sent strictly in order, one `sendMessage` at a time, so they never
556
+ * interleave with each other.
469
557
  *
470
558
  * Optional `topicId` pins the message to a forum topic (`message_thread_id`).
471
- * A definitive missing-thread reject retries once as a flat chat and warns, so a
472
- * deleted topic degrades instead of silently losing the page (#318).
559
+ * A definitive missing-thread reject retries once as a flat chat and warns, so
560
+ * a deleted topic degrades instead of silently losing the page (#318). The
561
+ * flat retry re-sends the whole split: parts already accepted went into a
562
+ * thread Telegram has just told us is gone, so nothing is readable there that
563
+ * a flat resend would duplicate.
473
564
  */
474
565
  export async function sendTelegram(
475
566
  token: string,
476
567
  chatId: string,
477
568
  text: string,
478
569
  opts?: { topicId?: number },
479
- ): Promise<number | undefined> {
570
+ ): Promise<number[]> {
480
571
  const topicId =
481
572
  opts?.topicId !== undefined && Number.isFinite(opts.topicId) && Number.isSafeInteger(opts.topicId)
482
573
  ? opts.topicId
483
574
  : undefined;
575
+ const parts = telegramTextParts(text);
484
576
  try {
485
- return await postTelegramMessage(token, chatId, text, topicId);
577
+ return await postTelegramParts(token, chatId, parts, topicId);
486
578
  } catch (err) {
487
579
  if (
488
580
  topicId === undefined ||
@@ -495,8 +587,23 @@ export async function sendTelegram(
495
587
  warn(
496
588
  `escalation.telegramTopicId=${topicId} is stale (${err.message}); retrying flat chat`,
497
589
  );
498
- return await postTelegramMessage(token, chatId, text, undefined);
590
+ return await postTelegramParts(token, chatId, parts, undefined);
591
+ }
592
+ }
593
+
594
+ /** One part per `sendMessage`, in order, collecting the ids Telegram returns. */
595
+ async function postTelegramParts(
596
+ token: string,
597
+ chatId: string,
598
+ parts: readonly string[],
599
+ topicId: number | undefined,
600
+ ): Promise<number[]> {
601
+ const ids: number[] = [];
602
+ for (const part of parts) {
603
+ const id = await postTelegramMessage(token, chatId, part, topicId);
604
+ if (id !== undefined) ids.push(id);
499
605
  }
606
+ return ids;
500
607
  }
501
608
 
502
609
  /** Telegram's definitive "that forum topic is gone" answers. */
@@ -513,11 +620,11 @@ async function postTelegramMessage(
513
620
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
514
621
  const payload: Record<string, unknown> = {
515
622
  chat_id: chatId,
516
- // ponytail: hard truncation rather than splitting across messages the
517
- // tail of a stack trace is rarely the interesting part. Upgrade path is to
518
- // attach the overflow as a file via sendDocument.
519
- text:
520
- text.length > TELEGRAM_TEXT_LIMIT ? `${text.slice(0, TELEGRAM_TEXT_LIMIT)}\n[truncated]` : text,
623
+ // `sendTelegram` splits long text into labelled parts above this call, so
624
+ // the text handed here is always a whole message: a part under the wire
625
+ // limit, or a message that never needed splitting. Nothing is truncated —
626
+ // a hard slice here is how the digest lost its tail (#566).
627
+ text,
521
628
  disable_web_page_preview: true,
522
629
  };
523
630
  if (topicId !== undefined) payload.message_thread_id = topicId;
@@ -157,6 +157,35 @@ export function providerTransientFault(error: { status?: number; message: string
157
157
  return error.message.split("\n")[0]?.trim() ?? error.message;
158
158
  }
159
159
 
160
+ /**
161
+ * How many in-session provider 429s read as *sustained* rate limiting rather
162
+ * than a retried blip (#573).
163
+ *
164
+ * A single 429 the harness retried and recovered from is noise — counting it
165
+ * would reclassify every run that ever hit one throttle as a provider fault
166
+ * and let real failures escape the failed-attempt budget. Only a run that kept
167
+ * hitting the throttle never gets clear of it, and that is the signature the
168
+ * classifier needs. Three matches the "three strikes" motif this file already
169
+ * uses (`DISPATCH_INFRA_MAX_STRIKES`, `PROVIDER_TRANSIENT_MAX_STRIKES`).
170
+ */
171
+ const SUSTAINED_RATE_LIMIT_COUNT = 3;
172
+
173
+ /**
174
+ * Evidence that this run drowned in in-session provider rate limiting, or
175
+ * `undefined` when it did not.
176
+ *
177
+ * Reads the count the worker recorded while the run was live — never a string
178
+ * match over the transcript, and never `lastError`: omp retried the 429s
179
+ * internally, so they never surfaced one `lastError`, which is exactly why the
180
+ * run was previously unclassifiable (#573). 429 is deliberately its own class:
181
+ * a generic 429 is rate limiting, a 402 is credit, a stream abort is
182
+ * transient — three failures with three remedies (#220).
183
+ */
184
+ export function providerCapacityFault(count: number | undefined): string | undefined {
185
+ if (count === undefined || count < SUSTAINED_RATE_LIMIT_COUNT) return undefined;
186
+ return `${count} in-session provider rate limits (HTTP 429) — the harness retried and was exhausted`;
187
+ }
188
+
160
189
  /**
161
190
  * Evidence that this run never started, or `undefined` when something did happen.
162
191
  *
@@ -300,6 +329,24 @@ export function classifyRun(
300
329
  }
301
330
  }
302
331
 
332
+ // Sustained in-session provider rate limiting: the run drowned in HTTP 429s
333
+ // the harness retried and never surfaced one as `lastError`, so neither the
334
+ // credit nor the transient branch sees it and it used to fall through to
335
+ // `unknown`, spending an implementation attempt on the provider's capacity
336
+ // (#573). Only the worker-recorded count — above a *sustained* threshold, so
337
+ // a single retried 429 on an otherwise healthy run stays untouched — reads as
338
+ // this. Distinct from the two provider classes on purpose and requeued free,
339
+ // bounded by its own strike cap in the daemon so a permanently throttled
340
+ // provider escalates instead of looping. Ahead of `neverStarted` for the same
341
+ // reason as the credit branch: a turn-0 run that immediately drowned in 429s
342
+ // would otherwise be absorbed by `env-start-failure` and lose its cause.
343
+ if (run.state === "failed" || run.state === "killed") {
344
+ const capacity = providerCapacityFault(run.provider429Count);
345
+ if (capacity !== undefined) {
346
+ return { cls: "provider-capacity", recovery: "requeue", evidence: capacity };
347
+ }
348
+ }
349
+
303
350
  // A run that never started is the most classifiable failure there is, and the
304
351
  // least deserving of an implementation attempt: the session did not get as far
305
352
  // as reading the issue. See {@link neverStarted} for the two shapes and why the