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/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
|
-
//
|
|
157
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|