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/src/escalate.ts CHANGED
@@ -31,6 +31,68 @@ import type { Escalation, ProjectConfig, Store, Tracker } from "./types.ts";
31
31
  /** Telegram rejects `sendMessage` over 4096 chars; leave room for the marker. */
32
32
  const TELEGRAM_TEXT_LIMIT = 4000;
33
33
 
34
+ /**
35
+ * Whether a failed send settles the question of delivery. This distinction is
36
+ * the whole of #123's honesty: both classes retry, but only one of them may be
37
+ * producing a *second* copy of a message Telegram already accepted, and only
38
+ * that one is allowed to say so.
39
+ *
40
+ * - `definitive` — Telegram answered and did not accept the message, or the
41
+ * request provably never left this host. Nobody has it. A retry is a first
42
+ * attempt, not a possible duplicate.
43
+ * - `unknown` — the request left and its fate was never learned. Telegram may
44
+ * be holding the message. A retry may duplicate, and must carry that warning.
45
+ */
46
+ export type TelegramSendOutcome = "definitive" | "unknown";
47
+
48
+ /** A send failure that knows which of the two it is. */
49
+ export class TelegramSendError extends Error {
50
+ readonly outcome: TelegramSendOutcome;
51
+ constructor(message: string, outcome: TelegramSendOutcome) {
52
+ super(message);
53
+ this.name = "TelegramSendError";
54
+ this.outcome = outcome;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Error codes that prove no request was ever put on the wire. Bun reports both
60
+ * a refused connection and a DNS failure as `ConnectionRefused`; the `E*` codes
61
+ * are what Node/undici produce, kept so this classification does not silently
62
+ * invert if the runtime underneath ever changes.
63
+ */
64
+ const NEVER_CONNECTED: ReadonlySet<string> = new Set([
65
+ "ConnectionRefused",
66
+ "FailedToOpenSocket",
67
+ "ECONNREFUSED",
68
+ "ENOTFOUND",
69
+ "EAI_AGAIN",
70
+ "EHOSTUNREACH",
71
+ "ENETUNREACH",
72
+ ]);
73
+
74
+ /**
75
+ * Classifies a `fetch` rejection, defaulting to `unknown`.
76
+ *
77
+ * The default is the entire point and must not be "tidied" into the simpler
78
+ * branch: an aborted request, a socket reset or an `EPIPE` all happen *after*
79
+ * the bytes went out, and Telegram may well have accepted the message before
80
+ * the connection died. Guessing `definitive` there re-sends it as though
81
+ * nothing had happened, which is how a delivered page is posted twice with
82
+ * nothing saying so. Guessing `unknown` costs a possible-repeat marker on a
83
+ * message that was never sent — a cosmetic wrong, traded against a silent
84
+ * duplicate. When in doubt, doubt out loud.
85
+ */
86
+ export function telegramFailureOutcome(cause: unknown): TelegramSendOutcome {
87
+ let error: unknown = cause;
88
+ for (let depth = 0; error instanceof Error && depth < 4; depth += 1) {
89
+ const code: unknown = (error as { code?: unknown }).code;
90
+ if (typeof code === "string" && NEVER_CONNECTED.has(code)) return "definitive";
91
+ error = error.cause;
92
+ }
93
+ return "unknown";
94
+ }
95
+
34
96
  export interface Escalator {
35
97
  escalate(e: Escalation): Promise<void>;
36
98
  }
@@ -152,9 +214,17 @@ export function createEscalator(
152
214
  const token = readTelegramToken();
153
215
  if (token) {
154
216
  // A send failure throws: `markNotified` stays uncalled so the next
155
- // poll retries instead of writing the event off as delivered.
156
- // ponytail: no retry/backoff in here — the dispatcher tick is the
157
- // retry. Upgrade path is a durable outbox table in the store.
217
+ // poll retries instead of writing the event off as delivered. No
218
+ // backoff in here — the dispatcher tick *is* the retry, and an
219
+ // escalation the loop keeps rediscovering is re-derived from the
220
+ // world rather than replayed from a queue.
221
+ //
222
+ // #123 built the durable outbox this comment used to name as the
223
+ // upgrade path, and deliberately did not move escalations into it:
224
+ // the condition that raises one is still true on the next tick, so
225
+ // the tick re-raises it. A *report* has no such source — it exists
226
+ // once, in the model's head, and nothing regenerates it — which is
227
+ // why that one needed a ledger and this one does not.
158
228
  await sendTelegram(token, chatId, text);
159
229
  store.markNotified(key);
160
230
  return;
@@ -207,8 +277,12 @@ function errText(e: unknown): string {
207
277
  * omp-telegram owns `<state dir>/.env`; conductor only borrows the token, so a
208
278
  * user already running that bot gets tier-2 pings with no extra configuration.
209
279
  * Absence is not an error — it just means tier 2 degrades to the fallback.
280
+ *
281
+ * Exported for the report outbox (#123), which pages over the same bot and must
282
+ * resolve the token the same way. Two readers of one `.env` is fine; two
283
+ * *implementations* of the parse below is how they drift.
210
284
  */
211
- function readTelegramToken(): string | undefined {
285
+ export function readTelegramToken(): string | undefined {
212
286
  const override = process.env.OMP_TELEGRAM_STATE_DIR?.trim();
213
287
  const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
214
288
  let raw: string;
@@ -232,7 +306,19 @@ function readTelegramToken(): string | undefined {
232
306
  return undefined;
233
307
  }
234
308
 
235
- async function sendTelegram(token: string, chatId: string, text: string): Promise<void> {
309
+ /**
310
+ * The one Telegram send in this package. Exported so the report outbox (#123)
311
+ * reuses it rather than forking it: the response handling below is load-bearing
312
+ * and was paid for once already (see the comment on the parse). Resolves with
313
+ * Telegram's own message id when it returns one, and throws on every *known*
314
+ * failure — connection refused, HTTP error, `{"ok":false}` — which is what lets
315
+ * a caller treat a throw as "nobody has this" and a crash as "nobody knows".
316
+ */
317
+ export async function sendTelegram(
318
+ token: string,
319
+ chatId: string,
320
+ text: string,
321
+ ): Promise<number | undefined> {
236
322
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
237
323
  const body = JSON.stringify({
238
324
  chat_id: chatId,
@@ -252,7 +338,10 @@ async function sendTelegram(token: string, chatId: string, text: string): Promis
252
338
  // at you — redact before this string reaches a log or an issue comment.
253
339
  const nested = cause instanceof Error && cause.cause instanceof Error ? `: ${cause.cause.message}` : "";
254
340
  const reason = cause instanceof Error ? `${cause.message}${nested}` : String(cause);
255
- throw new Error(`telegram sendMessage failed: ${redact(reason, token)}`);
341
+ throw new TelegramSendError(
342
+ `telegram sendMessage failed: ${redact(reason, token)}`,
343
+ telegramFailureOutcome(cause),
344
+ );
256
345
  }
257
346
 
258
347
  // The raw body is what gets parsed; `diagnostic` is only ever for humans.
@@ -264,24 +353,62 @@ async function sendTelegram(token: string, chatId: string, text: string): Promis
264
353
  // written on success, that also re-sent the same page every tick. Redaction
265
354
  // stays out of the parse for the same reason: it rewrites the very bytes the
266
355
  // decision is read from.
267
- const raw = await res.text().catch(() => "");
356
+ let raw: string;
357
+ let bodyUnread: unknown;
358
+ try {
359
+ raw = await res.text();
360
+ } catch (cause) {
361
+ raw = "";
362
+ bodyUnread = cause;
363
+ }
268
364
  const diagnostic = redact(raw, token).slice(0, 400);
269
365
  if (!res.ok) {
270
- throw new Error(`telegram sendMessage failed: HTTP ${res.status} ${diagnostic}`);
366
+ throw new TelegramSendError(
367
+ `telegram sendMessage failed: HTTP ${res.status} ${diagnostic}`,
368
+ "definitive",
369
+ );
370
+ }
371
+ // A 200 whose body never arrived is the one case where the status reads as
372
+ // encouraging and proves nothing: Telegram had already decided, and the answer
373
+ // was lost coming back. Reported as `unknown` rather than rejected so the
374
+ // retry carries a possible-repeat marker instead of quietly posting a second
375
+ // copy of a message that very probably landed. Checked after the status,
376
+ // because a non-ok status settles the question on its own.
377
+ if (bodyUnread !== undefined) {
378
+ const reason = bodyUnread instanceof Error ? bodyUnread.message : String(bodyUnread);
379
+ throw new TelegramSendError(
380
+ `telegram sendMessage left but its response body could not be read: ${redact(reason, token)}`,
381
+ "unknown",
382
+ );
271
383
  }
272
384
 
273
385
  // Telegram answers 200 with `{"ok":false}` for plenty of real failures
274
386
  // (kicked from the chat, bad chat_id), so the status alone proves nothing.
275
387
  let ok = false;
388
+ let messageId: number | undefined;
276
389
  try {
277
390
  const parsed: unknown = JSON.parse(raw);
278
391
  ok = typeof parsed === "object" && parsed !== null && "ok" in parsed && parsed.ok === true;
392
+ // Read from the same parse the ok/not-ok decision comes from, and only from
393
+ // the body — the outbox records this id as the durable proof of *which*
394
+ // attempt landed, and this transport offers no other reconciliation. A
395
+ // missing or odd `result` is not a delivery failure: `ok: true` is the
396
+ // verdict, the id is a courtesy, and inventing one would be worse than
397
+ // recording none.
398
+ if (typeof parsed === "object" && parsed !== null && "result" in parsed) {
399
+ const result: unknown = parsed.result;
400
+ if (typeof result === "object" && result !== null && "message_id" in result) {
401
+ const id: unknown = result.message_id;
402
+ if (typeof id === "number" && Number.isSafeInteger(id)) messageId = id;
403
+ }
404
+ }
279
405
  } catch {
280
406
  ok = false;
281
407
  }
282
408
  if (!ok) {
283
- throw new Error(`telegram sendMessage rejected: ${diagnostic}`);
409
+ throw new TelegramSendError(`telegram sendMessage rejected: ${diagnostic}`, "definitive");
284
410
  }
411
+ return messageId;
285
412
  }
286
413
 
287
414
  /**
package/src/fleet.ts CHANGED
@@ -27,11 +27,17 @@ import {
27
27
  import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
- import { findProject, loadConfig, stateDir } from "./config.ts";
30
+ import { findProject, loadConfig, resolveCredentials, stateDir } from "./config.ts";
31
+ import { describeBoundary, probeHost } from "./credentials.ts";
32
+ import { planUsageLine, readPlanUsage, sharedUsageSource } from "./usage.ts";
31
33
  import { readApprovalSurface } from "./approval-surface.ts";
34
+ import { confinementRefusalsToday, type ConfinementRefusalSummary } from "./confinement.ts";
35
+ import { settlementFlagSummary } from "./diff-flags.ts";
32
36
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
37
+ import { formatOpenReports } from "./reports.ts";
33
38
  import {
34
39
  formatDispatchSummary,
40
+ formatReleaseGrants,
35
41
  formatSalvagedRuns,
36
42
  isPaused,
37
43
  setPaused,
@@ -930,6 +936,16 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
930
936
  ].join("\n");
931
937
  }
932
938
 
939
+ /**
940
+ * The credential boundary row (#125): which mechanism is actually live, and the
941
+ * residuals it does *not* close. Passed in rather than probed inside the
942
+ * formatter so the wording is testable without a host.
943
+ */
944
+ export interface BoundaryStatus {
945
+ headline: string;
946
+ detail: string[];
947
+ }
948
+
933
949
  export function formatFleetStatus(
934
950
  s: StatusSnapshot,
935
951
  layers: FleetLayers,
@@ -937,6 +953,8 @@ export function formatFleetStatus(
937
953
  telegram: TelegramHealth = { kind: "unprobed" },
938
954
  now = Date.now(),
939
955
  codeGraph: CodeGraphHealth = { configured: false },
956
+ refusals: ConfinementRefusalSummary | undefined = undefined,
957
+ boundary: BoundaryStatus | undefined = undefined,
940
958
  ): string {
941
959
  const tickLine =
942
960
  layers.ticksDetail === undefined
@@ -989,6 +1007,24 @@ export function formatFleetStatus(
989
1007
  }
990
1008
 
991
1009
  const graphBlock = formatCodeGraphHealth(codeGraph, now);
1010
+ // Silent when there were none: a line reading "0" every day is one nobody
1011
+ // reads on the day it says 40. A repeatedly-refused orchestrator is
1012
+ // misbriefed, and that is the operator's problem to see (#127).
1013
+ const confinementLine =
1014
+ refusals === undefined
1015
+ ? undefined
1016
+ : `confine ${refusals.count} orchestrator refusal(s) today ` +
1017
+ `(latest: ${refusals.latest.tool} ${refusals.latest.path} — ${refusals.latest.kind})`;
1018
+ // Reported every time, never only when it is bad. An operator reading this
1019
+ // has to be able to see "unprotected" on the day they assumed otherwise, and
1020
+ // a line that appears only in the failure case is one whose absence means
1021
+ // nothing (#125). `undefined` here is a status rendered without a host probe,
1022
+ // which is itself worth saying rather than silently omitting.
1023
+ const boundaryLines =
1024
+ boundary === undefined
1025
+ ? ["boundary unprobed (no host capability probe was run for this status)"]
1026
+ : [`boundary ${boundary.headline}`, ...boundary.detail.map((d) => ` ${d}`)];
1027
+
992
1028
  return [
993
1029
  `dispatch ${layers.dispatch}`,
994
1030
  tickLine,
@@ -997,6 +1033,8 @@ export function formatFleetStatus(
997
1033
  recoveryLine,
998
1034
  herdrLine,
999
1035
  telegramLine,
1036
+ ...boundaryLines,
1037
+ ...(confinementLine === undefined ? [] : [confinementLine]),
1000
1038
  ...(graphBlock === undefined ? [] : [graphBlock]),
1001
1039
  daemonBlock,
1002
1040
  "",
@@ -1016,11 +1054,17 @@ function formatProjectBody(s: StatusSnapshot): string {
1016
1054
  s.caps.dailySpendUsd === null
1017
1055
  ? ` spend today $${s.spendTodayUsd.toFixed(2)} (no daily cap)`
1018
1056
  : ` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
1057
+ // Its own row beside the spend row, never folded into it: they are two
1058
+ // independent controls and an operator has to see which one stopped the
1059
+ // fleet (#110).
1060
+ ` plan usage ${planUsageLine(s.planUsage)}`,
1019
1061
  ` new worker turns ${s.caps.workerMaxTurns}`,
1020
1062
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
1021
1063
  ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
1022
1064
  ` continuations ${s.caps.maxContinuationsPerIssue}`,
1023
1065
  "",
1066
+ ...formatReleaseGrants(s.releaseGrants),
1067
+ "",
1024
1068
  formatDispatchSummary(s.dispatch),
1025
1069
  "",
1026
1070
  ];
@@ -1034,9 +1078,15 @@ function formatProjectBody(s: StatusSnapshot): string {
1034
1078
  `${r.turns}/${r.maxTurns} turns $${r.spendUsd.toFixed(2)} ${r.branch}` +
1035
1079
  (r.prUrl ? ` ${r.prUrl}` : ""),
1036
1080
  );
1081
+ // The orchestrator's Duty 1 reads this command, and a flagged run's
1082
+ // escalation is deduplicated after one delivery — so this is where a
1083
+ // flagged PR stays visible for as long as it is still open (#128).
1084
+ const flagged = settlementFlagSummary(r.settlementFlags);
1085
+ if (flagged !== undefined) lines.push(` ${flagged}`);
1037
1086
  }
1038
1087
  }
1039
1088
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1089
+ lines.push(...formatOpenReports(s.openReports));
1040
1090
  if (s.liveWorkers > 0) {
1041
1091
  lines.push(
1042
1092
  "",
@@ -1052,13 +1102,31 @@ export async function renderStatus(projectName?: string): Promise<string> {
1052
1102
  const layers = fleetLayers(projectName);
1053
1103
  const project = findProject(loadConfig(), projectName);
1054
1104
  const rec = livingDaemon();
1055
- const [health, telegram] = await Promise.all([
1105
+ const [health, telegram, planUsage] = await Promise.all([
1056
1106
  rec === undefined ? undefined : healthCheck(rec.port),
1057
1107
  probeTelegramHealth(projectName),
1108
+ // Read here rather than in `statusSnapshot`, which is synchronous and used
1109
+ // by callers that must not shell out. An unmetered project never spawns
1110
+ // the provider at all.
1111
+ readPlanUsage(s.caps.planUsage, sharedUsageSource()),
1058
1112
  ]);
1059
1113
  const cached = codeGraphFromHealthz(health?.body, project.name);
1060
1114
  const codeGraph = cached ?? (await probeCodeGraph(project));
1061
- return formatFleetStatus(s, layers, health, telegram, Date.now(), codeGraph);
1115
+ // Probed here, per `status` call, rather than read off the daemon: an
1116
+ // operator asking whether their fleet is protected must get the answer for
1117
+ // the host they are standing on, including when no daemon is running.
1118
+ const credentials = resolveCredentials(project);
1119
+ const probe = await probeHost({ slots: s.caps.maxConcurrentWorkers });
1120
+ return formatFleetStatus(
1121
+ { ...s, planUsage },
1122
+ layers,
1123
+ health,
1124
+ telegram,
1125
+ Date.now(),
1126
+ codeGraph,
1127
+ confinementRefusalsToday(),
1128
+ describeBoundary(credentials.isolation, probe),
1129
+ );
1062
1130
  }
1063
1131
 
1064
1132
  // ---------------------------------------------------------------------------