omp-conductor 0.15.5 → 0.15.6
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/package.json +1 -1
- package/src/board.ts +6 -28
- package/src/fleet.ts +154 -16
- package/src/setup-host.ts +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/board.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
4
4
|
import { findProject, loadConfig, resolveCaps } from "./config.ts";
|
|
5
5
|
import { statusSnapshotFromStore, type StatusSnapshot } from "./daemon.ts";
|
|
6
6
|
import {
|
|
7
|
+
classifyDaemonProjectHealth,
|
|
7
8
|
codeGraphFromHealthz,
|
|
8
9
|
fleetLayers,
|
|
9
10
|
probeTelegramHealth,
|
|
@@ -926,39 +927,16 @@ export function renderBoard(
|
|
|
926
927
|
async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealthProbe> {
|
|
927
928
|
const layers = fleetLayers(project.name);
|
|
928
929
|
const record = livingDaemon();
|
|
930
|
+
// Same gate as status (#379): never health-check a record pinned to another
|
|
931
|
+
// project, and never invent a second membership convention beside
|
|
932
|
+
// classifyDaemonProjectHealth.
|
|
929
933
|
const wrongRecord = record?.project !== undefined && record.project !== project.name;
|
|
930
934
|
const [telegram, health] = await Promise.all([
|
|
931
935
|
probeTelegramHealth(project.name),
|
|
932
936
|
record === undefined || wrongRecord ? undefined : healthCheck(record.port),
|
|
933
937
|
]);
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
else if (wrongRecord) daemon = "other-project";
|
|
937
|
-
else if (health?.ok !== true) daemon = "unreachable";
|
|
938
|
-
else {
|
|
939
|
-
try {
|
|
940
|
-
const payload = JSON.parse(health.body ?? "null") as unknown;
|
|
941
|
-
if (payload === null || typeof payload !== "object") {
|
|
942
|
-
daemon = "unreachable";
|
|
943
|
-
} else if (Reflect.get(payload, "project") === project.name) {
|
|
944
|
-
daemon = "ok";
|
|
945
|
-
} else {
|
|
946
|
-
const projects = Reflect.get(payload, "projects");
|
|
947
|
-
daemon =
|
|
948
|
-
Array.isArray(projects) &&
|
|
949
|
-
projects.some(
|
|
950
|
-
(entry) =>
|
|
951
|
-
entry !== null &&
|
|
952
|
-
typeof entry === "object" &&
|
|
953
|
-
Reflect.get(entry, "project") === project.name,
|
|
954
|
-
)
|
|
955
|
-
? "ok"
|
|
956
|
-
: "other-project";
|
|
957
|
-
}
|
|
958
|
-
} catch {
|
|
959
|
-
daemon = "unreachable";
|
|
960
|
-
}
|
|
961
|
-
}
|
|
938
|
+
const classified = classifyDaemonProjectHealth(record, health, project.name);
|
|
939
|
+
const daemon: DaemonBoardState = classified.kind;
|
|
962
940
|
const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
|
|
963
941
|
return {
|
|
964
942
|
health: {
|
package/src/fleet.ts
CHANGED
|
@@ -55,8 +55,10 @@ import {
|
|
|
55
55
|
healthCheck,
|
|
56
56
|
isAlive,
|
|
57
57
|
livingDaemon,
|
|
58
|
+
probeUnit,
|
|
58
59
|
stopDaemon,
|
|
59
60
|
type StopResult,
|
|
61
|
+
type UnitOwnership,
|
|
60
62
|
SYSTEMD_UNIT,
|
|
61
63
|
} from "./lifecycle.ts";
|
|
62
64
|
import { formatRss, rssBytesFromHealthz } from "./host.ts";
|
|
@@ -99,7 +101,7 @@ export const ARM_CHALLENGE_TIMEOUT_MS = 300_000;
|
|
|
99
101
|
const LEGACY_HERDR_SESSION = "fleet";
|
|
100
102
|
|
|
101
103
|
export const LEGACY_HERDR_SESSION_HINT =
|
|
102
|
-
'herdr session "fleet" found — rename it to "conductor" or
|
|
104
|
+
'herdr session "fleet" found — rename it to "conductor" or run omp-conductor setup host to pin HERDR_SESSION=fleet (remove this bridge next minor)';
|
|
103
105
|
|
|
104
106
|
let legacyHerdrSessionHintPrinted = false;
|
|
105
107
|
|
|
@@ -1270,10 +1272,132 @@ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()):
|
|
|
1270
1272
|
}
|
|
1271
1273
|
|
|
1272
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* Project-scoped reading of a living daemon record + `/healthz` body.
|
|
1277
|
+
*
|
|
1278
|
+
* Board and `status` share this so a host running one daemon for several
|
|
1279
|
+
* projects cannot be called healthy for a project it does not serve (#379).
|
|
1280
|
+
* A third interpretation convention is forbidden.
|
|
1281
|
+
*/
|
|
1282
|
+
export type DaemonProjectHealth =
|
|
1283
|
+
| { kind: "stopped" }
|
|
1284
|
+
| { kind: "ok" }
|
|
1285
|
+
| { kind: "unreachable" }
|
|
1286
|
+
| { kind: "other-project"; serves?: string };
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Facts `formatFleetStatus` needs about the living daemon. Pure input — the
|
|
1290
|
+
* caller probes; the formatter never shells out (#379).
|
|
1291
|
+
*/
|
|
1292
|
+
export type FleetDaemonProbe = {
|
|
1293
|
+
project: DaemonProjectHealth;
|
|
1294
|
+
/** `/healthz` body when the probe is project-ok (rss / overlays). */
|
|
1295
|
+
body?: string;
|
|
1296
|
+
/** systemd ownership of the living record pid; omit when unprobed. */
|
|
1297
|
+
unit?: UnitOwnership;
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Decide whether a living record + optional `/healthz` answer serve `project`.
|
|
1302
|
+
*
|
|
1303
|
+
* Mirrors the board gate: a record pinned to another project is never probed
|
|
1304
|
+
* further; a multi-project payload is ok only when `project` appears in
|
|
1305
|
+
* `projects[]` (or as the top-level single-project `project` field).
|
|
1306
|
+
*/
|
|
1307
|
+
export function classifyDaemonProjectHealth(
|
|
1308
|
+
record: { project?: string } | undefined,
|
|
1309
|
+
health: { ok: boolean; body?: string } | undefined,
|
|
1310
|
+
project: string,
|
|
1311
|
+
): DaemonProjectHealth {
|
|
1312
|
+
if (record === undefined) return { kind: "stopped" };
|
|
1313
|
+
// Record project is authoritative when set — a single-project leftover from
|
|
1314
|
+
// #376 must not be presented as this project's daemon just because its port
|
|
1315
|
+
// still answers.
|
|
1316
|
+
if (record.project !== undefined && record.project !== project) {
|
|
1317
|
+
return { kind: "other-project", serves: record.project };
|
|
1318
|
+
}
|
|
1319
|
+
if (health?.ok !== true) return { kind: "unreachable" };
|
|
1320
|
+
try {
|
|
1321
|
+
const payload = JSON.parse(health.body ?? "null") as unknown;
|
|
1322
|
+
if (payload === null || typeof payload !== "object") {
|
|
1323
|
+
return { kind: "unreachable" };
|
|
1324
|
+
}
|
|
1325
|
+
if (Reflect.get(payload, "project") === project) return { kind: "ok" };
|
|
1326
|
+
const projects = Reflect.get(payload, "projects");
|
|
1327
|
+
if (
|
|
1328
|
+
Array.isArray(projects) &&
|
|
1329
|
+
projects.some(
|
|
1330
|
+
(entry) =>
|
|
1331
|
+
entry !== null &&
|
|
1332
|
+
typeof entry === "object" &&
|
|
1333
|
+
Reflect.get(entry, "project") === project,
|
|
1334
|
+
)
|
|
1335
|
+
) {
|
|
1336
|
+
return { kind: "ok" };
|
|
1337
|
+
}
|
|
1338
|
+
return { kind: "other-project", serves: servedProjectName(payload) };
|
|
1339
|
+
} catch {
|
|
1340
|
+
return { kind: "unreachable" };
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Best-effort name of who a foreign `/healthz` payload is actually serving. */
|
|
1345
|
+
function servedProjectName(payload: object): string | undefined {
|
|
1346
|
+
const top = Reflect.get(payload, "project");
|
|
1347
|
+
if (typeof top === "string" && top.length > 0) return top;
|
|
1348
|
+
const projects = Reflect.get(payload, "projects");
|
|
1349
|
+
if (!Array.isArray(projects)) return undefined;
|
|
1350
|
+
for (const entry of projects) {
|
|
1351
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
1352
|
+
const name = Reflect.get(entry, "project");
|
|
1353
|
+
if (typeof name === "string" && name.length > 0) return name;
|
|
1354
|
+
}
|
|
1355
|
+
return undefined;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function formatDaemonHealthz(probe: FleetDaemonProbe | undefined): string {
|
|
1359
|
+
if (probe === undefined) return "unprobed";
|
|
1360
|
+
switch (probe.project.kind) {
|
|
1361
|
+
case "stopped":
|
|
1362
|
+
return "stopped";
|
|
1363
|
+
case "ok":
|
|
1364
|
+
return "ok";
|
|
1365
|
+
case "unreachable":
|
|
1366
|
+
return "unreachable — the process is up but not serving";
|
|
1367
|
+
case "other-project":
|
|
1368
|
+
return probe.project.serves === undefined
|
|
1369
|
+
? "other-project"
|
|
1370
|
+
: `other-project — serves ${probe.project.serves}`;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Unit line for a living record. The bare unit name alone used to imply
|
|
1376
|
+
* systemd ownership that was never checked (#379); every branch states the
|
|
1377
|
+
* probe result, and `unknown` is never rendered as owned.
|
|
1378
|
+
*/
|
|
1379
|
+
function formatDaemonUnit(unit: UnitOwnership | undefined, recordPid: number): string {
|
|
1380
|
+
if (unit === undefined) return ` unit ${SYSTEMD_UNIT} unprobed`;
|
|
1381
|
+
switch (unit.kind) {
|
|
1382
|
+
case "active":
|
|
1383
|
+
if (unit.pid === recordPid) {
|
|
1384
|
+
return ` unit ${SYSTEMD_UNIT} systemd-owned`;
|
|
1385
|
+
}
|
|
1386
|
+
// Live unit, different MainPID — the record pid is not systemd's.
|
|
1387
|
+
return ` unit ${SYSTEMD_UNIT} unmanaged — MainPID ${unit.pid}`;
|
|
1388
|
+
case "failed":
|
|
1389
|
+
return ` unit ${SYSTEMD_UNIT} unmanaged — unit failed`;
|
|
1390
|
+
case "inactive":
|
|
1391
|
+
return ` unit ${SYSTEMD_UNIT} inactive`;
|
|
1392
|
+
case "unknown":
|
|
1393
|
+
return ` unit ${SYSTEMD_UNIT} unknown — ${unit.reason}`;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1273
1397
|
export function formatFleetStatus(
|
|
1274
1398
|
s: StatusSnapshot,
|
|
1275
1399
|
layers: FleetLayers,
|
|
1276
|
-
|
|
1400
|
+
daemon: FleetDaemonProbe | undefined = undefined,
|
|
1277
1401
|
telegram: TelegramHealth = { kind: "unprobed" },
|
|
1278
1402
|
now = Date.now(),
|
|
1279
1403
|
codeGraph: CodeGraphHealth = { configured: false },
|
|
@@ -1315,20 +1439,17 @@ export function formatFleetStatus(
|
|
|
1315
1439
|
if (!layers.daemon.running || layers.daemon.pid === undefined) {
|
|
1316
1440
|
daemonBlock = "daemon not running";
|
|
1317
1441
|
} else {
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
? "ok"
|
|
1323
|
-
: "unreachable — the process is up but not serving";
|
|
1324
|
-
const rss = rssBytesFromHealthz(daemonHealth?.body);
|
|
1442
|
+
// Only trust rss from a project-ok body — a foreign payload's bytes are
|
|
1443
|
+
// not this project's daemon facts (#379).
|
|
1444
|
+
const rss =
|
|
1445
|
+
daemon?.project.kind === "ok" ? rssBytesFromHealthz(daemon.body) : undefined;
|
|
1325
1446
|
daemonBlock = [
|
|
1326
1447
|
"daemon",
|
|
1327
1448
|
` pid ${layers.daemon.pid}`,
|
|
1328
1449
|
` port ${layers.daemon.port ?? "?"}`,
|
|
1329
1450
|
...(rss === undefined ? [] : [` rss ${formatRss(rss)}`]),
|
|
1330
|
-
` healthz ${
|
|
1331
|
-
|
|
1451
|
+
` healthz ${formatDaemonHealthz(daemon)}`,
|
|
1452
|
+
formatDaemonUnit(daemon?.unit, layers.daemon.pid),
|
|
1332
1453
|
].join("\n");
|
|
1333
1454
|
}
|
|
1334
1455
|
|
|
@@ -1504,8 +1625,11 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1504
1625
|
const layers = fleetLayers(projectName);
|
|
1505
1626
|
const project = findProject(loadConfig(), projectName);
|
|
1506
1627
|
const rec = livingDaemon();
|
|
1507
|
-
|
|
1508
|
-
|
|
1628
|
+
// Gate the port probe on record membership first — a leftover single-project
|
|
1629
|
+
// daemon from #376 must not be health-checked as if it served us (#379).
|
|
1630
|
+
const wrongRecord = rec?.project !== undefined && rec.project !== project.name;
|
|
1631
|
+
const [rawHealth, telegram, planUsage, github] = await Promise.all([
|
|
1632
|
+
rec === undefined || wrongRecord ? undefined : healthCheck(rec.port),
|
|
1509
1633
|
probeTelegramHealth(projectName),
|
|
1510
1634
|
// Read here rather than in `statusSnapshot`, which is synchronous and used
|
|
1511
1635
|
// by callers that must not shell out. An unmetered project never spawns
|
|
@@ -1516,13 +1640,27 @@ export async function renderStatus(projectName?: string): Promise<string> {
|
|
|
1516
1640
|
// broken report (#188).
|
|
1517
1641
|
fetchRateLimit(),
|
|
1518
1642
|
]);
|
|
1519
|
-
const
|
|
1643
|
+
const projectHealth = classifyDaemonProjectHealth(rec, rawHealth, project.name);
|
|
1644
|
+
// Ownership is probed, never assumed from the unit name constant (#379).
|
|
1645
|
+
const unit = rec === undefined ? undefined : probeUnit();
|
|
1646
|
+
const daemon: FleetDaemonProbe | undefined =
|
|
1647
|
+
rec === undefined
|
|
1648
|
+
? undefined
|
|
1649
|
+
: {
|
|
1650
|
+
project: projectHealth,
|
|
1651
|
+
...(projectHealth.kind === "ok" && rawHealth?.body !== undefined
|
|
1652
|
+
? { body: rawHealth.body }
|
|
1653
|
+
: {}),
|
|
1654
|
+
...(unit === undefined ? {} : { unit }),
|
|
1655
|
+
};
|
|
1656
|
+
const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
|
|
1657
|
+
const cached = codeGraphFromHealthz(healthBody, project.name);
|
|
1520
1658
|
const codeGraph = cached ?? (await probeCodeGraph(project));
|
|
1521
|
-
const workerPhases = workerPhasesFromHealthz(
|
|
1659
|
+
const workerPhases = workerPhasesFromHealthz(healthBody, project.name);
|
|
1522
1660
|
return formatFleetStatus(
|
|
1523
1661
|
{ ...s, planUsage, github },
|
|
1524
1662
|
layers,
|
|
1525
|
-
|
|
1663
|
+
daemon,
|
|
1526
1664
|
telegram,
|
|
1527
1665
|
Date.now(),
|
|
1528
1666
|
codeGraph,
|
package/src/setup-host.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir, userInfo } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
6
6
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
7
|
-
import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
|
|
7
|
+
import { DEFAULT_FLEET_AGENT_NAME, DEFAULT_HERDR_SESSION, resolveHerdrSessionWithBridge } from "./fleet.ts";
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_PORT,
|
|
10
10
|
healthCheck,
|
|
@@ -65,6 +65,12 @@ export interface ServiceRuntime {
|
|
|
65
65
|
packageCli: string;
|
|
66
66
|
conductorHome: string;
|
|
67
67
|
telegramStateDir: string;
|
|
68
|
+
/**
|
|
69
|
+
* Non-default Herdr session to pin into the unit (#394). Omitted when the
|
|
70
|
+
* host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
|
|
71
|
+
* as drift on every existing host and add a redundant default.
|
|
72
|
+
*/
|
|
73
|
+
herdrSession?: string;
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
function systemdQuote(value: string): string {
|
|
@@ -237,7 +243,12 @@ function refusal(
|
|
|
237
243
|
}
|
|
238
244
|
|
|
239
245
|
|
|
240
|
-
function defaultServiceRuntime(
|
|
246
|
+
function defaultServiceRuntime(
|
|
247
|
+
telegramStateDir: string,
|
|
248
|
+
// Bridge-resolved session name. Passed in so tests stay hermetic (no herdr
|
|
249
|
+
// spawn) and the renderer never shells out — same threading as telegramStateDir.
|
|
250
|
+
herdrSession: string = resolveHerdrSessionWithBridge(),
|
|
251
|
+
): ServiceRuntime {
|
|
241
252
|
const home = homedir();
|
|
242
253
|
const bun = process.execPath;
|
|
243
254
|
const globalCli = Bun.which("omp-conductor");
|
|
@@ -253,6 +264,9 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
|
253
264
|
packageCli: join(import.meta.dir, "cli.ts"),
|
|
254
265
|
conductorHome: dirname(configPath()),
|
|
255
266
|
telegramStateDir,
|
|
267
|
+
// Only pin a non-default session. Default hosts keep a unit with no
|
|
268
|
+
// HERDR_SESSION line so setup/upgrade do not invent drift (#394).
|
|
269
|
+
...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
|
|
256
270
|
};
|
|
257
271
|
}
|
|
258
272
|
|
|
@@ -283,6 +297,11 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
283
297
|
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
284
298
|
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
285
299
|
`Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
|
|
300
|
+
// Escape hatch for hosts still on the pre-#320 "fleet" session (#394). Only
|
|
301
|
+
// when set: an always-on line would drift every default host on every setup.
|
|
302
|
+
...(runtime.herdrSession === undefined
|
|
303
|
+
? []
|
|
304
|
+
: [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
|
|
286
305
|
`WorkingDirectory=${systemdPath(stateDir())}`,
|
|
287
306
|
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
288
307
|
"Restart=on-failure",
|